Merge pull request #1 from Open3DLab/copilot/create-emote-database-api

feat: Rust emote database and REST API (axum + sqlx + S3)
This commit is contained in:
Ganonmaster
2026-03-18 14:55:10 +01:00
committed by GitHub
15 changed files with 4880 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-linux:
name: Build Linux release binary
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # needed so build.rs can run `git describe`
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Cargo registry and build artefacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build release binary
run: cargo build --release --target x86_64-unknown-linux-gnu
- name: Rename binary for upload
run: |
cp target/x86_64-unknown-linux-gnu/release/mikebase \
mikebase-${{ github.ref_name }}-x86_64-linux
- name: Upload binary to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: mikebase-${{ github.ref_name }}-x86_64-linux
generate_release_notes: true
+5
View File
@@ -19,3 +19,8 @@ target
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Added by cargo
/target
Generated
+4055
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "mikebase"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "uuid", "chrono", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
config = "0.14"
aws-config = "1"
aws-sdk-s3 = "1"
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "1"
mime_guess = "2"
tokio-util = { version = "0.7", features = ["io"] }
bytes = "1"
+41
View File
@@ -0,0 +1,41 @@
use std::process::Command;
fn main() {
// Emit git commit hash (short)
let commit_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=GIT_COMMIT_HASH={commit_hash}");
// Emit git tag describing the current commit (e.g. "v1.0.0" or "v1.0.0-3-gabcdef1")
// `git describe --tags --exact-match` gives the exact tag; fall back to `git describe --tags`.
let git_tag = Command::new("git")
.args(["describe", "--tags", "--exact-match"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.or_else(|| {
Command::new("git")
.args(["describe", "--tags"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
});
let tag_value = git_tag.unwrap_or_else(|| "untagged".to_string());
println!("cargo:rustc-env=GIT_TAG={tag_value}");
// Re-run if HEAD or tag refs change
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs/tags");
println!("cargo:rerun-if-changed=.git/packed-refs");
}
+22
View File
@@ -0,0 +1,22 @@
# mikebase configuration file
# All values can be overridden by environment variables using the APP__ prefix.
# Example: APP__DATABASE__URL=sqlite://mikebase.db
[database]
# SQLite example:
url = "sqlite://mikebase.db"
# PostgreSQL example:
# url = "postgresql://user:password@localhost/mikebase"
[server]
host = "0.0.0.0"
port = 3000
[s3]
endpoint = "https://s3.eu-central-1.wasabisys.com"
region = "eu-central-1"
bucket = "open3dlab-emoji"
access_key = "YOUR_ACCESS_KEY"
secret_key = "YOUR_SECRET_KEY"
# Base URL used to construct public image URLs in API responses.
public_url = "https://s3.eu-central-1.wasabisys.com/open3dlab-emoji"
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS emotes (
uuid TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
alias TEXT,
image_key TEXT NOT NULL,
created TEXT NOT NULL,
modified TEXT NOT NULL
);
+72
View File
@@ -0,0 +1,72 @@
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct S3Config {
pub endpoint: String,
pub region: String,
pub bucket: String,
pub access_key: String,
pub secret_key: String,
/// Public base URL used to build emote image URLs returned in API responses.
/// Example: "https://s3.eu-central-1.wasabisys.com/open3dlab-emoji"
pub public_url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
/// Database URL.
/// SQLite example: "sqlite://mikebase.db"
/// PostgreSQL example: "postgresql://user:pass@localhost/mikebase"
pub url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn default_port() -> u16 {
3000
}
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub s3: S3Config,
pub database: DatabaseConfig,
#[serde(default)]
pub server: ServerConfig,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
}
}
}
impl AppConfig {
pub fn load() -> Result<Self, ConfigError> {
let cfg = Config::builder()
// Optional config file (config.toml)
.add_source(File::with_name("config").required(false))
// Environment variables with prefix APP (e.g. APP__S3__BUCKET)
.add_source(
Environment::with_prefix("APP")
.separator("__")
.try_parsing(true),
)
.build()?;
cfg.try_deserialize()
}
}
+128
View File
@@ -0,0 +1,128 @@
use std::sync::Arc;
use chrono::Utc;
use sqlx::AnyPool;
use crate::{
config::AppConfig,
models::{EmoteRow, new_uuid},
};
/// Thin database abstraction that works with both SQLite and PostgreSQL
/// through `sqlx::AnyPool`.
#[derive(Clone)]
pub struct Database {
pub pool: AnyPool,
pub config: Arc<AppConfig>,
}
impl Database {
pub async fn connect(config: Arc<AppConfig>) -> Result<Self, sqlx::Error> {
let pool = AnyPool::connect(&config.database.url).await?;
Ok(Self { pool, config })
}
/// Run pending migrations.
pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(&self.pool).await
}
pub async fn list_emotes(&self) -> Result<Vec<EmoteRow>, sqlx::Error> {
let rows = sqlx::query_as::<_, EmoteRow>(
"SELECT uuid, name, alias, image_key, created, modified FROM emotes ORDER BY created DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_emote_by_id(&self, uuid: &str) -> Result<Option<EmoteRow>, sqlx::Error> {
let row = sqlx::query_as::<_, EmoteRow>(
"SELECT uuid, name, alias, image_key, created, modified FROM emotes WHERE uuid = $1",
)
.bind(uuid)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn create_emote(
&self,
name: &str,
alias: Option<&str>,
image_key: &str,
) -> Result<EmoteRow, sqlx::Error> {
let id = new_uuid();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO emotes (uuid, name, alias, image_key, created, modified) VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(&id)
.bind(name)
.bind(alias)
.bind(image_key)
.bind(&now)
.bind(&now)
.execute(&self.pool)
.await?;
Ok(EmoteRow {
uuid: id,
name: name.to_string(),
alias: alias.map(|s| s.to_string()),
image_key: image_key.to_string(),
modified: now.clone(),
created: now,
})
}
pub async fn update_emote(
&self,
uuid: &str,
name: Option<&str>,
alias: Option<Option<&str>>,
image_key: Option<&str>,
) -> Result<Option<EmoteRow>, sqlx::Error> {
let existing = match self.get_emote_by_id(uuid).await? {
Some(e) => e,
None => return Ok(None),
};
let new_name = name.unwrap_or(&existing.name);
let new_image_key = image_key.unwrap_or(&existing.image_key);
let new_alias: Option<String> = match alias {
Some(Some(a)) => Some(a.to_string()),
Some(None) => None,
None => existing.alias.clone(),
};
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE emotes SET name = $1, alias = $2, image_key = $3, modified = $4 WHERE uuid = $5",
)
.bind(new_name)
.bind(new_alias.as_deref())
.bind(new_image_key)
.bind(&now)
.bind(uuid)
.execute(&self.pool)
.await?;
Ok(Some(EmoteRow {
uuid: uuid.to_string(),
name: new_name.to_string(),
alias: new_alias,
image_key: new_image_key.to_string(),
created: existing.created,
modified: now,
}))
}
pub async fn delete_emote(&self, uuid: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM emotes WHERE uuid = $1")
.bind(uuid)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
}
+73
View File
@@ -0,0 +1,73 @@
mod config;
mod db;
mod models;
mod routes;
mod storage;
use std::sync::Arc;
use axum::{
routing::{delete, get, post, put},
Router,
};
use sqlx::any::install_default_drivers;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{config::AppConfig, db::Database, storage::S3Storage};
/// Shared application state injected into every handler.
#[derive(Clone)]
pub struct AppState {
pub db: Database,
pub storage: S3Storage,
}
#[tokio::main]
async fn main() {
// Initialise structured logging.
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mikebase=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Load configuration from config.toml / environment variables.
let cfg = AppConfig::load().expect("Failed to load configuration");
let cfg = Arc::new(cfg);
// Install SQLite and PostgreSQL drivers for sqlx AnyPool.
install_default_drivers();
// Connect to the database and run migrations.
let db = Database::connect(cfg.clone())
.await
.expect("Failed to connect to database");
db.migrate().await.expect("Failed to run migrations");
// Build S3 storage client.
let storage = S3Storage::new(&cfg);
let state = AppState { db, storage };
let app = Router::new()
.route("/", get(routes::emotes::root))
.route("/version", get(routes::version::version))
.route("/json", get(routes::emotes::list_emotes))
.route("/emotes", post(routes::emotes::create_emote))
.route("/emotes/{uuid}", put(routes::emotes::update_emote))
.route("/emotes/{uuid}", delete(routes::emotes::delete_emote))
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = format!("{}:{}", cfg.server.host, cfg.server.port);
tracing::info!("Listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("Failed to bind address");
axum::serve(listener, app)
.await
.expect("Server error");
}
+53
View File
@@ -0,0 +1,53 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Row as stored in the database.
/// Timestamps are stored as ISO 8601 strings because `sqlx::Any` does not
/// provide a blanket `Encode`/`Decode` impl for `chrono::DateTime<Utc>`.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct EmoteRow {
pub uuid: String,
pub name: String,
pub alias: Option<String>,
pub image_key: String,
/// ISO 8601 string, e.g. "2022-02-13T11:27:38.219685+00:00"
pub created: String,
/// ISO 8601 string
pub modified: String,
}
impl EmoteRow {
pub fn created_dt(&self) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(&self.created)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now())
}
pub fn modified_dt(&self) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(&self.modified)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now())
}
}
/// JSON representation returned by the API.
#[derive(Debug, Serialize, Deserialize)]
pub struct EmoteResponse {
pub name: String,
pub url: String,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
}
/// Payload for updating an existing emote.
#[derive(Debug, Deserialize)]
pub struct UpdateEmoteRequest {
pub name: Option<String>,
pub alias: Option<String>,
pub image_key: Option<String>,
}
pub fn new_uuid() -> String {
Uuid::new_v4().to_string()
}
+240
View File
@@ -0,0 +1,240 @@
use axum::{
extract::{Multipart, Path, State},
http::StatusCode,
response::{IntoResponse, Json},
};
use serde_json::json;
use crate::{
models::{EmoteResponse, UpdateEmoteRequest},
AppState,
};
/// GET /
/// Returns a simple health-check message.
pub async fn root() -> impl IntoResponse {
Json(json!({"status": "ok", "message": "mikebase server is running"}))
}
/// GET /json
/// Returns all emotes formatted as {"emotes": [...]}.
pub async fn list_emotes(State(state): State<AppState>) -> impl IntoResponse {
match state.db.list_emotes().await {
Ok(rows) => {
let emotes: Vec<EmoteResponse> = rows
.into_iter()
.map(|row| EmoteResponse {
name: row.name.clone(),
url: state.storage.public_url(&row.image_key),
created: row.created_dt(),
modified: row.modified_dt(),
})
.collect();
(StatusCode::OK, Json(json!({"emotes": emotes}))).into_response()
}
Err(e) => {
tracing::error!("Failed to list emotes: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to list emotes"})),
)
.into_response()
}
}
}
/// POST /emotes
/// Create a new emote. Accepts multipart/form-data with:
/// - `name` (text)
/// - `alias` (text, optional)
/// - `file` (binary the emote image)
pub async fn create_emote(
State(state): State<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut name: Option<String> = None;
let mut alias: Option<String> = None;
let mut file_bytes: Option<bytes::Bytes> = None;
let mut file_name: Option<String> = None;
let mut content_type: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
match field.name() {
Some("name") => {
name = field.text().await.ok();
}
Some("alias") => {
alias = field.text().await.ok();
}
Some("file") => {
file_name = field.file_name().map(|s| s.to_string());
content_type = field.content_type().map(|s| s.to_string());
file_bytes = field.bytes().await.ok();
}
_ => {}
}
}
let name = match name {
Some(n) if !n.is_empty() => n,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Missing required field: name"})),
)
.into_response();
}
};
let (bytes, fname) = match (file_bytes, file_name) {
(Some(b), Some(n)) => (b, n),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Missing required field: file"})),
)
.into_response();
}
};
let ct = content_type
.unwrap_or_else(|| mime_guess::from_path(&fname).first_or_octet_stream().to_string());
let key = format!("emoji/{fname}");
match state.storage.upload(&key, bytes, &ct).await {
Ok(_) => {}
Err(e) => {
tracing::error!("S3 upload failed: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to upload image"})),
)
.into_response();
}
}
match state
.db
.create_emote(&name, alias.as_deref(), &key)
.await
{
Ok(row) => {
let url = state.storage.public_url(&row.image_key);
let created = row.created_dt();
let modified = row.modified_dt();
let resp = EmoteResponse {
name: row.name,
url,
created,
modified,
};
(StatusCode::CREATED, Json(json!(resp))).into_response()
}
Err(e) => {
tracing::error!("DB insert failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to create emote"})),
)
.into_response()
}
}
}
/// PUT /emotes/:uuid
/// Update an existing emote's metadata.
/// Accepts JSON body with optional fields: `name`, `alias`, `image_key`.
pub async fn update_emote(
State(state): State<AppState>,
Path(uuid): Path<String>,
Json(payload): Json<UpdateEmoteRequest>,
) -> impl IntoResponse {
let alias_update: Option<Option<&str>> = payload.alias.as_ref().map(|a| Some(a.as_str()));
match state
.db
.update_emote(
&uuid,
payload.name.as_deref(),
alias_update,
payload.image_key.as_deref(),
)
.await
{
Ok(Some(row)) => {
let url = state.storage.public_url(&row.image_key);
let created = row.created_dt();
let modified = row.modified_dt();
let resp = EmoteResponse {
name: row.name,
url,
created,
modified,
};
(StatusCode::OK, Json(json!(resp))).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "Emote not found"})),
)
.into_response(),
Err(e) => {
tracing::error!("DB update failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to update emote"})),
)
.into_response()
}
}
}
/// DELETE /emotes/:uuid
/// Delete an emote and its associated image from S3.
pub async fn delete_emote(
State(state): State<AppState>,
Path(uuid): Path<String>,
) -> impl IntoResponse {
// Fetch the row first so we can clean up S3.
let row = match state.db.get_emote_by_id(&uuid).await {
Ok(Some(r)) => r,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "Emote not found"})),
)
.into_response();
}
Err(e) => {
tracing::error!("DB fetch failed: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to fetch emote"})),
)
.into_response();
}
};
// Delete from S3 (best-effort don't abort if this fails).
if let Err(e) = state.storage.delete(&row.image_key).await {
tracing::warn!("S3 delete failed for key {}: {e}", row.image_key);
}
match state.db.delete_emote(&uuid).await {
Ok(true) => (StatusCode::NO_CONTENT).into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "Emote not found"})),
)
.into_response(),
Err(e) => {
tracing::error!("DB delete failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to delete emote"})),
)
.into_response()
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod emotes;
pub mod version;
+25
View File
@@ -0,0 +1,25 @@
use axum::response::{IntoResponse, Json};
use serde_json::json;
/// GET /version
/// Returns the current git commit hash and tag (if present).
pub async fn version() -> impl IntoResponse {
let commit = env!("GIT_COMMIT_HASH");
let tag = env!("GIT_TAG");
// `git describe --tags --exact-match` sets an exact semver-like tag when the
// commit is tagged; otherwise the build script stores "untagged".
let response = if tag == "untagged" {
json!({
"commit": commit,
"version": null
})
} else {
json!({
"commit": commit,
"version": tag
})
};
Json(response)
}
+81
View File
@@ -0,0 +1,81 @@
use aws_config::Region;
use aws_credential_types::Credentials;
use aws_sdk_s3::{
config::{BehaviorVersion, Builder as S3Builder, SharedCredentialsProvider},
primitives::ByteStream,
Client,
};
use bytes::Bytes;
use crate::config::AppConfig;
#[derive(Clone)]
pub struct S3Storage {
client: Client,
bucket: String,
public_url: String,
}
impl S3Storage {
pub fn new(config: &AppConfig) -> Self {
let credentials = Credentials::new(
&config.s3.access_key,
&config.s3.secret_key,
None,
None,
"static",
);
let s3_config = S3Builder::new()
.behavior_version(BehaviorVersion::latest())
.endpoint_url(&config.s3.endpoint)
.region(Region::new(config.s3.region.clone()))
.credentials_provider(SharedCredentialsProvider::new(credentials))
.force_path_style(true)
.build();
let client = Client::from_conf(s3_config);
Self {
client,
bucket: config.s3.bucket.clone(),
public_url: config.s3.public_url.trim_end_matches('/').to_string(),
}
}
/// Upload raw bytes to S3 under `key` and return the public URL.
pub async fn upload(
&self,
key: &str,
data: Bytes,
content_type: &str,
) -> Result<String, aws_sdk_s3::Error> {
self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(data))
.content_type(content_type)
.send()
.await?;
Ok(self.public_url(key))
}
/// Delete an object from S3.
pub async fn delete(&self, key: &str) -> Result<(), aws_sdk_s3::Error> {
self.client
.delete_object()
.bucket(&self.bucket)
.key(key)
.send()
.await?;
Ok(())
}
/// Build the public URL for an object key.
pub fn public_url(&self, key: &str) -> String {
let key = key.trim_start_matches('/');
format!("{}/{}", self.public_url, key)
}
}