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:
@@ -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
|
||||||
@@ -19,3 +19,8 @@ target
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# 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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
|
||||||
|
/target
|
||||||
|
|||||||
Generated
+4055
File diff suppressed because it is too large
Load Diff
+24
@@ -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"
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod emotes;
|
||||||
|
pub mod version;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user