Implement Rust-based emote database and REST API
Co-authored-by: Ganonmaster <168445+Ganonmaster@users.noreply.github.com>
This commit is contained in:
@@ -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
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,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)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
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("/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 @@
|
||||
pub mod emotes;
|
||||
@@ -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