20 Commits

Author SHA1 Message Date
Ganonmaster b7365ef1e9 v0.3.0
Release / Build and push Docker image (push) Successful in 14m9s
Release / Build Linux release binary (push) Successful in 15m47s
2026-04-28 11:46:00 +02:00
Ganonmaster 2c219f5565 Fix S3 collision, alias clearing, upload limit, TOCTOU; add tests
Bug fixes:
- S3 key is now emoji/{uuid}.{ext} instead of emoji/{filename},
  preventing silent overwrites when two emotes share a filename
- UpdateEmoteRequest.alias uses Option<Option<String>> with a custom
  deserializer so a JSON null clears the alias rather than being ignored;
  the manage UI now sends null when the alias field is emptied
- POST /emotes is limited to 8 MiB via DefaultBodyLimit
- update_emote replaced the fetch-then-update pair with a single
  UPDATE … RETURNING using COALESCE/CASE WHEN, eliminating the TOCTOU
  race between concurrent edits

Refactoring:
- Extracted src/lib.rs so domain logic is a library crate; src/main.rs
  is now a thin startup entry point
- auth::check decoupled from AppState — takes Option<&AuthConfig> directly
- Removed unused config field from Database struct

Tests (40 total):
- auth: 10 unit tests covering all check() branches
- models: 6 unit tests for timestamp parsing and alias deserialization
- db: 9 unit tests against in-memory SQLite covering full CRUD
- routes: 15 integration tests in tests/routes.rs covering auth
  middleware, input validation, and all mutating endpoints
2026-04-28 11:31:25 +02:00
Ganonmaster 08fd6cea70 Add HTTP Basic Auth
All write endpoints and the manage
routes are gated behind HTTP Basic Auth middleware; credentials are
configured via [auth] in config.toml or APP__AUTH__USERNAME /
APP__AUTH__PASSWORD environment variables.
2026-04-28 03:23:57 +02:00
Ganonmaster c13f5b9a88 Add emote management UI at /manage
Adds a server-side HTML management page and supporting backend for
full CRUD on emotes. The /manage prefix is kept isolated so an auth
middleware can be applied to it later without touching other routes.

- GET /manage — serves the management UI (add, edit, delete, search)
- GET /manage/emotes — admin JSON endpoint with uuid and alias included
- AdminEmoteResponse model for the expanded admin representation
- Client-side search filters by name or alias as you type
2026-04-28 03:14:54 +02:00
Ganonmaster 6a86cc8cce Release v0.2.4 2026-04-11 22:53:33 +02:00
Ganonmaster 6df3ab1db1 Release v0.2.3 2026-04-11 22:03:08 +02:00
Ganonmaster 80cbc66878 Update gitea workflow 2026-04-11 21:33:59 +02:00
Ganonmaster b4a887603e Update Registry token. 2026-04-09 17:39:44 +02:00
Ganonmaster e27587a990 Remove old github workflows 2026-04-09 17:33:42 +02:00
Ganonmaster 92f834a52b Merge branch 'main' of git.open3dlab.com:Open3DLab/mikebase 2026-04-09 17:31:18 +02:00
Ganonmaster 9f9ada8150 Add docker build, rewrite Actions for Gitea, and add healthcheck endpoint. 2026-04-09 17:26:35 +02:00
Ganonmaster f5cb4b31eb Merge pull request #2 from Open3DLab/copilot/add-emotes-display-and-healthcheck
Add emotes display page, health endpoint, Dockerfile, and Docker CI
2026-04-07 02:41:03 +02:00
Ganonmaster 85498a357f Spice up HTML page styling with accent colors, drop shadow, and glow effects 2026-04-07 00:40:02 +00:00
Ganonmaster 2d4ad79857 Add error handling to emotes fetch call 2026-04-07 00:25:09 +00:00
Ganonmaster 5d9fc26011 Add emotes HTML page at /, health endpoint, Dockerfile, and Docker workflow 2026-04-07 00:21:42 +00:00
Ganonmaster 55f42ac1f3 Merge pull request #1 from Open3DLab/copilot/create-emote-database-api
feat: Rust emote database and REST API (axum + sqlx + S3)
2026-03-18 14:55:10 +01:00
Ganonmaster abc6cf1ecd Add /version endpoint and GitHub Actions release workflow 2026-03-18 13:42:40 +00:00
Ganonmaster 33acab96a2 Implement Rust-based emote database and REST API 2026-03-18 13:18:40 +00:00
Ganonmaster 4642587f5c Initial plan 2026-03-18 12:54:45 +00:00
Ganonmaster 0a36aeb434 Initial commit 2026-03-18 13:54:42 +01:00
17 changed files with 1419 additions and 91 deletions
+24 -12
View File
@@ -52,23 +52,35 @@ jobs:
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
# Create the release # Gitea may not have finished processing the tag ref by the time the
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ # workflow runner starts, so retry a few times before giving up.
-H "Authorization: token ${GITEA_TOKEN}" \ RELEASE_ID=""
-H "Content-Type: application/json" \ for attempt in 1 2 3 4 5; do
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}") -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1) HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1) BODY=$(echo "$RESPONSE" | head -1)
if [ "$HTTP_CODE" -ne 201 ]; then if [ "$HTTP_CODE" -eq 201 ]; then
echo "Failed to create release (HTTP $HTTP_CODE): $BODY" RELEASE_ID=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
break
fi
echo "Attempt $attempt failed (HTTP $HTTP_CODE): $BODY"
if [ "$attempt" -lt 5 ]; then
sleep $((attempt * 5))
fi
done
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release after 5 attempts"
exit 1 exit 1
fi fi
RELEASE_ID=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Upload binary asset # Upload binary asset
BINARY="mikebase-${TAG}-x86_64-linux" BINARY="mikebase-${TAG}-x86_64-linux"
curl -s -X POST \ curl -s -X POST \
+81
View File
@@ -0,0 +1,81 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.0] - 2026-04-28
### Added
- Management UI at `GET /manage` for adding, editing, and deleting emotes.
- `GET /manage/emotes` — admin JSON endpoint returning full emote data (uuid,
name, alias, url, timestamps) for use by the management UI.
- `AdminEmoteResponse` model exposing `uuid` and `alias` fields not present in
the public `EmoteResponse`.
- Client-side search on the management page filtering emotes by name or alias.
- HTTP Basic Auth middleware protecting `/manage` and all emote write endpoints
(`POST /emotes`, `PUT /emotes/{uuid}`, `DELETE /emotes/{uuid}`). Credentials
are configured via `[auth]` in `config.toml` or the `APP__AUTH__USERNAME` /
`APP__AUTH__PASSWORD` environment variables. Omitting the section causes all
protected routes to return 401.
## [0.2.4] - 2026-04-11
### Fixed
- Gitea release workflow: remove `target_commitish` from release creation API
call to fix 409 conflict when creating releases for existing tags.
## [0.2.3] - 2026-04-11
### Fixed
- Gitea release workflow: retry release creation with backoff to handle timing
between tag push and tag availability in the API.
## [0.2.2] - 2026-04-09
### Fixed
- Updated Gitea container registry token in CI.
## [0.2.1] - 2026-04-09
### Removed
- Deleted old GitHub Actions workflows (superseded by Gitea workflows).
## [0.2.0] - 2026-04-09
### Added
- Emotes listing HTML page at `GET /` with styled table (accent colors, drop
shadow, glow effects).
- `GET /` health check endpoint (returns 200 OK).
- Dockerfile and Docker build/push workflow targeting the Gitea container
registry.
### Fixed
- Error handling added to the client-side emotes fetch call on the HTML page.
## [0.1.0] - 2026-03-18
### Added
- Rust/Axum HTTP server with SQLite and PostgreSQL support via `sqlx::AnyPool`.
- `EmoteRow` DB model and `EmoteResponse` JSON API response type.
- `POST /emotes` — create emote (multipart: `name`, `alias?`, `file`).
- `PUT /emotes/{uuid}` — update emote metadata.
- `DELETE /emotes/{uuid}` — delete emote from DB and S3.
- `GET /json` — list all emotes as JSON.
- `GET /version` — git commit hash and tag baked in at build time via
`build.rs`.
- S3/Wasabi storage backend with configurable endpoint and public URL.
- Database migrations run automatically on startup.
- GitHub Actions release workflow building a Linux binary on `v*` tag push.
[Unreleased]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.3.0...HEAD
[0.3.0]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.2.4...v0.3.0
[0.2.4]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.2.3...v0.2.4
[0.2.3]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.2.2...v0.2.3
[0.2.2]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.2.1...v0.2.2
[0.2.1]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.open3dlab.com/Open3DLab/mikebase/releases/tag/v0.1.0
+26 -1
View File
@@ -56,4 +56,29 @@ The app is an Axum HTTP server with shared state (`AppState`) containing two com
## Releases ## Releases
Pushing a `v*` tag triggers the GitHub Actions release workflow, which builds a Linux binary and uploads it to a GitHub Release. Pushing a `v*` tag triggers the Gitea Actions release workflow, which builds a
Linux binary, uploads it to a Gitea Release, and pushes a Docker image to the
Gitea container registry.
### Cutting a release
1. Move the `[Unreleased]` entries in `CHANGELOG.md` to a new versioned section:
```markdown
## [0.x.y] - YYYY-MM-DD
```
2. Add a new empty `## [Unreleased]` section at the top.
3. Update the comparison links at the bottom of `CHANGELOG.md`:
- Change the `[Unreleased]` link to compare the new tag against `HEAD`.
- Add a new link for the new version comparing it against the previous tag.
4. Commit: `git commit -m "Release v0.x.y"`
5. Tag and push:
```bash
git tag v0.x.y
git push origin main v0.x.y
```
### Changelog conventions
`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Group entries under: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
Record notable changes in `[Unreleased]` as you make them, not only at release time.
Generated
+2
View File
@@ -1851,6 +1851,7 @@ dependencies = [
"aws-credential-types", "aws-credential-types",
"aws-sdk-s3", "aws-sdk-s3",
"axum", "axum",
"base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
"config", "config",
@@ -1861,6 +1862,7 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
+4
View File
@@ -22,3 +22,7 @@ thiserror = "1"
mime_guess = "2" mime_guess = "2"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
bytes = "1" bytes = "1"
base64 = "0.22"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
+7
View File
@@ -12,6 +12,13 @@ url = "sqlite://mikebase.db"
host = "0.0.0.0" host = "0.0.0.0"
port = 3000 port = 3000
[auth]
# Credentials for the /manage UI and emote write endpoints (POST/PUT/DELETE).
# Can also be set via APP__AUTH__USERNAME and APP__AUTH__PASSWORD env vars.
# If omitted, all protected routes return 401.
username = "admin"
password = "changeme"
[s3] [s3]
endpoint = "https://s3.eu-central-1.wasabisys.com" endpoint = "https://s3.eu-central-1.wasabisys.com"
region = "eu-central-1" region = "eu-central-1"
+140
View File
@@ -0,0 +1,140 @@
use axum::{
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::{engine::general_purpose::STANDARD, Engine};
use crate::{config::AuthConfig, AppState};
static WWW_AUTHENTICATE: HeaderValue =
HeaderValue::from_static("Basic realm=\"mikebase\", charset=\"UTF-8\"");
pub async fn require_basic_auth(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
if !check(state.cfg.auth.as_ref(), request.headers().get(header::AUTHORIZATION)) {
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, WWW_AUTHENTICATE.clone())],
)
.into_response();
}
next.run(request).await
}
fn check(creds: Option<&AuthConfig>, header: Option<&HeaderValue>) -> bool {
let creds = match creds {
Some(c) => c,
None => return false,
};
let value = match header.and_then(|v| v.to_str().ok()) {
Some(v) => v,
None => return false,
};
let encoded = match value.strip_prefix("Basic ") {
Some(s) => s,
None => return false,
};
let decoded = match STANDARD.decode(encoded) {
Ok(b) => b,
Err(_) => return false,
};
let text = match std::str::from_utf8(&decoded) {
Ok(s) => s,
Err(_) => return false,
};
// Split on the first colon only — passwords may contain colons.
let (user, pass) = match text.split_once(':') {
Some(pair) => pair,
None => return false,
};
// Compare both fields regardless of which one fails to avoid leaking
// information about which part was wrong.
let user_ok = user == creds.username;
let pass_ok = pass == creds.password;
user_ok & pass_ok
}
#[cfg(test)]
mod tests {
use super::*;
fn creds(username: &str, password: &str) -> AuthConfig {
AuthConfig { username: username.to_string(), password: password.to_string() }
}
fn basic_header(user: &str, pass: &str) -> HeaderValue {
let encoded = STANDARD.encode(format!("{user}:{pass}"));
HeaderValue::from_str(&format!("Basic {encoded}")).unwrap()
}
#[test]
fn no_auth_config_always_denies() {
assert!(!check(None, None));
assert!(!check(None, Some(&basic_header("admin", "secret"))));
}
#[test]
fn missing_header_denies() {
assert!(!check(Some(&creds("admin", "secret")), None));
}
#[test]
fn valid_credentials_accepted() {
let h = basic_header("admin", "secret");
assert!(check(Some(&creds("admin", "secret")), Some(&h)));
}
#[test]
fn wrong_password_denied() {
let h = basic_header("admin", "wrong");
assert!(!check(Some(&creds("admin", "secret")), Some(&h)));
}
#[test]
fn wrong_username_denied() {
let h = basic_header("other", "secret");
assert!(!check(Some(&creds("admin", "secret")), Some(&h)));
}
#[test]
fn both_fields_wrong_denied() {
let h = basic_header("other", "wrong");
assert!(!check(Some(&creds("admin", "secret")), Some(&h)));
}
#[test]
fn non_basic_scheme_denied() {
let h = HeaderValue::from_static("Bearer sometoken");
assert!(!check(Some(&creds("admin", "secret")), Some(&h)));
}
#[test]
fn malformed_base64_denied() {
let h = HeaderValue::from_static("Basic not!!valid!!base64");
assert!(!check(Some(&creds("admin", "secret")), Some(&h)));
}
#[test]
fn no_colon_in_decoded_value_denied() {
let encoded = STANDARD.encode("adminnocolon");
let h = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap();
assert!(!check(Some(&creds("admin", "secret")), Some(&h)));
}
#[test]
fn password_containing_colons_accepted() {
let h = basic_header("admin", "pass:with:colons");
assert!(check(Some(&creds("admin", "pass:with:colons")), Some(&h)));
}
}
+7
View File
@@ -37,12 +37,19 @@ fn default_port() -> u16 {
3000 3000
} }
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct AppConfig { pub struct AppConfig {
pub s3: S3Config, pub s3: S3Config,
pub database: DatabaseConfig, pub database: DatabaseConfig,
#[serde(default)] #[serde(default)]
pub server: ServerConfig, pub server: ServerConfig,
pub auth: Option<AuthConfig>,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
+152 -39
View File
@@ -1,25 +1,19 @@
use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use sqlx::AnyPool; use sqlx::AnyPool;
use crate::{ use crate::models::EmoteRow;
config::AppConfig,
models::{EmoteRow, new_uuid},
};
/// Thin database abstraction that works with both SQLite and PostgreSQL /// Thin database abstraction that works with both SQLite and PostgreSQL
/// through `sqlx::AnyPool`. /// through `sqlx::AnyPool`.
#[derive(Clone)] #[derive(Clone)]
pub struct Database { pub struct Database {
pub pool: AnyPool, pub pool: AnyPool,
pub config: Arc<AppConfig>,
} }
impl Database { impl Database {
pub async fn connect(config: Arc<AppConfig>) -> Result<Self, sqlx::Error> { pub async fn connect(url: &str) -> Result<Self, sqlx::Error> {
let pool = AnyPool::connect(&config.database.url).await?; let pool = AnyPool::connect(url).await?;
Ok(Self { pool, config }) Ok(Self { pool })
} }
/// Run pending migrations. /// Run pending migrations.
@@ -48,16 +42,16 @@ impl Database {
pub async fn create_emote( pub async fn create_emote(
&self, &self,
uuid: &str,
name: &str, name: &str,
alias: Option<&str>, alias: Option<&str>,
image_key: &str, image_key: &str,
) -> Result<EmoteRow, sqlx::Error> { ) -> Result<EmoteRow, sqlx::Error> {
let id = new_uuid();
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
sqlx::query( sqlx::query(
"INSERT INTO emotes (uuid, name, alias, image_key, created, modified) VALUES ($1, $2, $3, $4, $5, $6)", "INSERT INTO emotes (uuid, name, alias, image_key, created, modified) VALUES ($1, $2, $3, $4, $5, $6)",
) )
.bind(&id) .bind(uuid)
.bind(name) .bind(name)
.bind(alias) .bind(alias)
.bind(image_key) .bind(image_key)
@@ -67,7 +61,7 @@ impl Database {
.await?; .await?;
Ok(EmoteRow { Ok(EmoteRow {
uuid: id, uuid: uuid.to_string(),
name: name.to_string(), name: name.to_string(),
alias: alias.map(|s| s.to_string()), alias: alias.map(|s| s.to_string()),
image_key: image_key.to_string(), image_key: image_key.to_string(),
@@ -83,39 +77,32 @@ impl Database {
alias: Option<Option<&str>>, alias: Option<Option<&str>>,
image_key: Option<&str>, image_key: Option<&str>,
) -> Result<Option<EmoteRow>, sqlx::Error> { ) -> 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(); let now = Utc::now().to_rfc3339();
// alias has three states: None = keep, Some(None) = clear, Some(Some(v)) = set.
// Pass a boolean flag so CASE WHEN can choose between the new value and the
// existing column — all resolved atomically in a single statement.
let alias_touch = alias.is_some();
let alias_value: Option<&str> = alias.flatten();
sqlx::query( let row = sqlx::query_as::<_, EmoteRow>(
"UPDATE emotes SET name = $1, alias = $2, image_key = $3, modified = $4 WHERE uuid = $5", "UPDATE emotes
SET name = COALESCE($1, name),
alias = CASE WHEN $2 THEN $3 ELSE alias END,
image_key = COALESCE($4, image_key),
modified = $5
WHERE uuid = $6
RETURNING uuid, name, alias, image_key, created, modified",
) )
.bind(new_name) .bind(name)
.bind(new_alias.as_deref()) .bind(alias_touch)
.bind(new_image_key) .bind(alias_value)
.bind(image_key)
.bind(&now) .bind(&now)
.bind(uuid) .bind(uuid)
.execute(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(Some(EmoteRow { Ok(row)
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> { pub async fn delete_emote(&self, uuid: &str) -> Result<bool, sqlx::Error> {
@@ -126,3 +113,129 @@ impl Database {
Ok(result.rows_affected() > 0) Ok(result.rows_affected() > 0)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::models::new_uuid;
async fn test_db() -> Database {
sqlx::any::install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let db = Database { pool };
db.migrate().await.unwrap();
db
}
#[tokio::test]
async fn create_and_get_by_id() {
let db = test_db().await;
let id = new_uuid();
let row = db.create_emote(&id, "cat", Some("kitty"), "emoji/cat.png").await.unwrap();
assert_eq!(row.uuid, id);
assert_eq!(row.name, "cat");
assert_eq!(row.alias.as_deref(), Some("kitty"));
assert_eq!(row.image_key, "emoji/cat.png");
let fetched = db.get_emote_by_id(&id).await.unwrap().unwrap();
assert_eq!(fetched.uuid, id);
assert_eq!(fetched.name, "cat");
}
#[tokio::test]
async fn get_unknown_uuid_returns_none() {
let db = test_db().await;
let result = db.get_emote_by_id("does-not-exist").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn list_returns_all_emotes_newest_first() {
let db = test_db().await;
let id1 = new_uuid();
let id2 = new_uuid();
db.create_emote(&id1, "alpha", None, "emoji/alpha.png").await.unwrap();
db.create_emote(&id2, "beta", None, "emoji/beta.png").await.unwrap();
let rows = db.list_emotes().await.unwrap();
assert_eq!(rows.len(), 2);
// ORDER BY created DESC — beta was inserted last so it comes first
assert_eq!(rows[0].name, "beta");
assert_eq!(rows[1].name, "alpha");
}
#[tokio::test]
async fn update_name() {
let db = test_db().await;
let id = new_uuid();
db.create_emote(&id, "old", None, "emoji/x.png").await.unwrap();
let updated = db.update_emote(&id, Some("new"), None, None).await.unwrap().unwrap();
assert_eq!(updated.name, "new");
assert_eq!(updated.image_key, "emoji/x.png"); // unchanged
}
#[tokio::test]
async fn update_unspecified_fields_are_kept() {
let db = test_db().await;
let id = new_uuid();
db.create_emote(&id, "name", Some("alias"), "emoji/x.png").await.unwrap();
// Pass None for all optional fields — nothing should change except modified timestamp.
let updated = db.update_emote(&id, None, None, None).await.unwrap().unwrap();
assert_eq!(updated.name, "name");
assert_eq!(updated.alias.as_deref(), Some("alias"));
assert_eq!(updated.image_key, "emoji/x.png");
}
#[tokio::test]
async fn update_alias_set_and_clear() {
let db = test_db().await;
let id = new_uuid();
db.create_emote(&id, "cat", None, "emoji/cat.png").await.unwrap();
// Set alias
let with_alias = db
.update_emote(&id, None, Some(Some("kitty")), None)
.await
.unwrap()
.unwrap();
assert_eq!(with_alias.alias.as_deref(), Some("kitty"));
// Clear alias
let cleared = db
.update_emote(&id, None, Some(None), None)
.await
.unwrap()
.unwrap();
assert!(cleared.alias.is_none());
}
#[tokio::test]
async fn update_unknown_uuid_returns_none() {
let db = test_db().await;
let result = db.update_emote("no-such-id", Some("x"), None, None).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn delete_removes_emote() {
let db = test_db().await;
let id = new_uuid();
db.create_emote(&id, "bye", None, "emoji/bye.png").await.unwrap();
assert!(db.delete_emote(&id).await.unwrap());
assert!(db.get_emote_by_id(&id).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_unknown_uuid_returns_false() {
let db = test_db().await;
assert!(!db.delete_emote("no-such-id").await.unwrap());
}
}
+49
View File
@@ -0,0 +1,49 @@
pub mod auth;
pub mod config;
pub mod db;
pub mod models;
pub mod routes;
pub mod storage;
use std::sync::Arc;
use axum::{
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, post, put},
Router,
};
use tower_http::trace::TraceLayer;
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,
pub cfg: Arc<AppConfig>,
}
pub fn build_router(state: AppState) -> Router {
let protected = Router::new()
.route("/manage", get(routes::manage::manage_root))
.route("/manage/emotes", get(routes::manage::list_admin_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(DefaultBodyLimit::max(8 * 1024 * 1024))
.layer(middleware::from_fn_with_state(
state.clone(),
auth::require_basic_auth,
));
Router::new()
.route("/", get(routes::emotes::root))
.route("/health", get(routes::health::health))
.route("/version", get(routes::version::version))
.route("/json", get(routes::emotes::list_emotes))
.merge(protected)
.layer(TraceLayer::new_for_http())
.with_state(state)
}
+4 -34
View File
@@ -1,28 +1,9 @@
mod config;
mod db;
mod models;
mod routes;
mod storage;
use std::sync::Arc; use std::sync::Arc;
use axum::{ use mikebase::{config::AppConfig, db::Database, storage::S3Storage, AppState, build_router};
routing::{delete, get, post, put},
Router,
};
use sqlx::any::install_default_drivers; use sqlx::any::install_default_drivers;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 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] #[tokio::main]
async fn main() { async fn main() {
// Initialise structured logging. // Initialise structured logging.
@@ -42,7 +23,7 @@ async fn main() {
install_default_drivers(); install_default_drivers();
// Connect to the database and run migrations. // Connect to the database and run migrations.
let db = Database::connect(cfg.clone()) let db = Database::connect(&cfg.database.url)
.await .await
.expect("Failed to connect to database"); .expect("Failed to connect to database");
db.migrate().await.expect("Failed to run migrations"); db.migrate().await.expect("Failed to run migrations");
@@ -50,25 +31,14 @@ async fn main() {
// Build S3 storage client. // Build S3 storage client.
let storage = S3Storage::new(&cfg); let storage = S3Storage::new(&cfg);
let state = AppState { db, storage }; let state = AppState { db, storage, cfg: cfg.clone() };
let app = Router::new()
.route("/", get(routes::emotes::root))
.route("/health", get(routes::health::health))
.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); let addr = format!("{}:{}", cfg.server.host, cfg.server.port);
tracing::info!("Listening on {addr}"); tracing::info!("Listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr) let listener = tokio::net::TcpListener::bind(&addr)
.await .await
.expect("Failed to bind address"); .expect("Failed to bind address");
axum::serve(listener, app) axum::serve(listener, build_router(state))
.await .await
.expect("Server error"); .expect("Server error");
} }
+87 -1
View File
@@ -40,14 +40,100 @@ pub struct EmoteResponse {
pub modified: DateTime<Utc>, pub modified: DateTime<Utc>,
} }
/// Extended JSON representation used by the management API (includes uuid and alias).
#[derive(Debug, Serialize, Deserialize)]
pub struct AdminEmoteResponse {
pub uuid: String,
pub name: String,
pub alias: Option<String>,
pub url: String,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
}
/// Payload for updating an existing emote. /// Payload for updating an existing emote.
///
/// `alias` uses a double-Option to distinguish three states:
/// absent → keep existing alias
/// null → clear alias
/// "value" → set alias to value
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateEmoteRequest { pub struct UpdateEmoteRequest {
pub name: Option<String>, pub name: Option<String>,
pub alias: Option<String>, #[serde(default, deserialize_with = "deserialize_optional_field")]
pub alias: Option<Option<String>>,
pub image_key: Option<String>, pub image_key: Option<String>,
} }
fn deserialize_optional_field<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::deserialize(de)?))
}
pub fn new_uuid() -> String { pub fn new_uuid() -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
#[cfg(test)]
mod tests {
use super::*;
use chrono::Datelike;
fn row(created: &str, modified: &str) -> EmoteRow {
EmoteRow {
uuid: "u".into(),
name: "n".into(),
alias: None,
image_key: "k".into(),
created: created.into(),
modified: modified.into(),
}
}
#[test]
fn created_dt_parses_valid_rfc3339() {
let r = row("2024-01-15T10:30:00+00:00", "2024-01-15T10:30:00+00:00");
let dt = r.created_dt();
assert_eq!(dt.year(), 2024);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
}
#[test]
fn modified_dt_parses_valid_rfc3339() {
let r = row("2024-01-01T00:00:00Z", "2025-06-20T12:00:00Z");
let dt = r.modified_dt();
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 6);
}
#[test]
fn invalid_timestamp_falls_back_to_now() {
let r = row("not-a-date", "not-a-date");
let before = Utc::now();
let dt = r.created_dt();
let after = Utc::now();
assert!(dt >= before && dt <= after);
}
#[test]
fn alias_absent_in_json_deserializes_to_none() {
let req: UpdateEmoteRequest = serde_json::from_str(r#"{"name":"foo"}"#).unwrap();
assert!(req.alias.is_none());
}
#[test]
fn alias_null_in_json_deserializes_to_some_none() {
let req: UpdateEmoteRequest = serde_json::from_str(r#"{"alias":null}"#).unwrap();
assert_eq!(req.alias, Some(None));
}
#[test]
fn alias_value_in_json_deserializes_to_some_some() {
let req: UpdateEmoteRequest = serde_json::from_str(r#"{"alias":"kitty"}"#).unwrap();
assert_eq!(req.alias, Some(Some("kitty".to_string())));
}
}
+10 -4
View File
@@ -6,7 +6,7 @@ use axum::{
use serde_json::json; use serde_json::json;
use crate::{ use crate::{
models::{EmoteResponse, UpdateEmoteRequest}, models::{new_uuid, EmoteResponse, UpdateEmoteRequest},
AppState, AppState,
}; };
@@ -100,7 +100,13 @@ pub async fn create_emote(
let ct = content_type let ct = content_type
.unwrap_or_else(|| mime_guess::from_path(&fname).first_or_octet_stream().to_string()); .unwrap_or_else(|| mime_guess::from_path(&fname).first_or_octet_stream().to_string());
let key = format!("emoji/{fname}"); let id = new_uuid();
let ext = std::path::Path::new(&fname)
.extension()
.and_then(|e| e.to_str())
.filter(|e| e.chars().all(|c| c.is_ascii_alphanumeric()))
.unwrap_or("bin");
let key = format!("emoji/{id}.{ext}");
match state.storage.upload(&key, bytes, &ct).await { match state.storage.upload(&key, bytes, &ct).await {
Ok(_) => {} Ok(_) => {}
@@ -116,7 +122,7 @@ pub async fn create_emote(
match state match state
.db .db
.create_emote(&name, alias.as_deref(), &key) .create_emote(&id, &name, alias.as_deref(), &key)
.await .await
{ {
Ok(row) => { Ok(row) => {
@@ -150,7 +156,7 @@ pub async fn update_emote(
Path(uuid): Path<String>, Path(uuid): Path<String>,
Json(payload): Json<UpdateEmoteRequest>, Json(payload): Json<UpdateEmoteRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let alias_update: Option<Option<&str>> = payload.alias.as_ref().map(|a| Some(a.as_str())); let alias_update: Option<Option<&str>> = payload.alias.as_ref().map(|opt| opt.as_deref());
match state match state
.db .db
+43
View File
@@ -0,0 +1,43 @@
use axum::{
extract::State,
http::StatusCode,
response::{Html, IntoResponse, Json},
};
use serde_json::json;
use crate::{models::AdminEmoteResponse, AppState};
/// GET /manage
/// Serves the emote management HTML page.
pub async fn manage_root() -> impl IntoResponse {
Html(include_str!("../templates/manage.html"))
}
/// GET /manage/emotes
/// Returns all emotes with full admin data (uuid, alias included).
pub async fn list_admin_emotes(State(state): State<AppState>) -> impl IntoResponse {
match state.db.list_emotes().await {
Ok(rows) => {
let emotes: Vec<AdminEmoteResponse> = rows
.into_iter()
.map(|row| AdminEmoteResponse {
uuid: row.uuid.clone(),
name: row.name.clone(),
alias: row.alias.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()
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod emotes; pub mod emotes;
pub mod health; pub mod health;
pub mod manage;
pub mod version; pub mod version;
+419
View File
@@ -0,0 +1,419 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MIKEBASE — Manage</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #222222; color: #ffffff; }
nav {
height: 60px; display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; background: #171717; border-bottom: 2px solid #4a0000;
box-shadow: 0 2px 12px rgba(74,0,0,0.5); position: relative; z-index: 10;
}
nav .logo { font-size: 1.5rem; letter-spacing: 0.08em; color: #ffffff; }
nav .logo strong { font-weight: 700; color: #ff4d4d; text-shadow: 0 0 8px rgba(74,0,0,0.8); }
nav .logo span { font-weight: 300; }
nav .logo .sub { font-size: 0.7rem; font-weight: 400; color: #888; letter-spacing: 0.15em; margin-left: 8px; vertical-align: middle; }
nav a.back { font-size: 0.85rem; color: #888; text-decoration: none; transition: color 0.2s; }
nav a.back:hover { color: #ff4d4d; }
.toolbar {
display: flex; align-items: center; gap: 12px;
padding: 20px 24px 16px;
}
.search-wrap { position: relative; flex: 1; max-width: 360px; }
.search-wrap input {
width: 100%; padding: 8px 12px 8px 36px;
background: #171717; border: 1px solid #333; border-radius: 8px;
color: #fff; font-size: 0.9rem; outline: none;
transition: border-color 0.2s;
}
.search-wrap input:focus { border-color: #4a0000; }
.search-wrap input::placeholder { color: #555; }
.search-wrap svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); opacity: 0.4; }
button.add-btn {
margin-left: auto; padding: 8px 18px;
background: #4a0000; border: none; border-radius: 8px;
color: #fff; font-size: 0.9rem; cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
}
button.add-btn:hover { background: #6b0000; box-shadow: 0 0 12px rgba(74,0,0,0.6); }
.list { padding: 0 24px 40px; }
table { width: 100%; border-collapse: collapse; }
thead th {
text-align: left; font-size: 0.75rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: #666; padding: 0 12px 10px; border-bottom: 1px solid #333;
}
tbody tr { border-bottom: 1px solid #2a2a2a; transition: background 0.15s; }
tbody tr:hover { background: rgba(74,0,0,0.08); }
tbody td { padding: 10px 12px; vertical-align: middle; }
td.td-img img { width: 48px; height: 48px; object-fit: contain; border-radius: 6px; }
td.td-name { font-size: 0.9rem; }
td.td-name .code { color: #ff4d4d; font-weight: 500; }
td.td-alias { font-size: 0.85rem; color: #888; }
td.td-actions { text-align: right; white-space: nowrap; }
.btn-edit, .btn-del, .btn-del-confirm {
padding: 5px 13px; border-radius: 6px; border: none;
font-size: 0.8rem; cursor: pointer; transition: background 0.2s;
}
.btn-edit { background: #2a2a2a; color: #ccc; margin-right: 6px; }
.btn-edit:hover { background: #383838; color: #fff; }
.btn-del { background: transparent; border: 1px solid #4a0000; color: #cc3333; }
.btn-del:hover { background: #4a0000; color: #fff; }
.btn-del-confirm { background: #8b0000; color: #fff; border: none; margin-right: 6px; }
.btn-del-confirm:hover { background: #a00000; }
.btn-cancel-del { background: transparent; border: 1px solid #444; color: #888; padding: 5px 13px; border-radius: 6px; font-size: 0.8rem; cursor: pointer; }
.btn-cancel-del:hover { border-color: #666; color: #ccc; }
.empty { padding: 48px; text-align: center; color: #555; }
/* Modal */
.overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.7); z-index: 100;
align-items: center; justify-content: center;
}
.overlay.open { display: flex; }
.modal {
background: #1a1a1a; border: 1px solid #3a0000;
border-radius: 12px; padding: 28px 32px;
width: 100%; max-width: 420px;
box-shadow: 0 0 40px rgba(74,0,0,0.4);
}
.modal h2 { font-size: 1.1rem; margin-bottom: 20px; color: #fff; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 0.8rem; color: #888; margin-bottom: 6px; letter-spacing: 0.05em; }
.field input[type=text], .field input[type=file] {
width: 100%; padding: 8px 12px;
background: #111; border: 1px solid #333; border-radius: 8px;
color: #fff; font-size: 0.9rem; outline: none;
transition: border-color 0.2s;
}
.field input[type=text]:focus { border-color: #4a0000; }
.field input[type=file] { color: #888; cursor: pointer; }
.field .hint { font-size: 0.75rem; color: #555; margin-top: 4px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 8px; }
.btn-submit {
padding: 8px 20px; background: #4a0000; border: none;
border-radius: 8px; color: #fff; font-size: 0.9rem; cursor: pointer;
transition: background 0.2s;
}
.btn-submit:hover { background: #6b0000; }
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-cancel {
padding: 8px 16px; background: transparent; border: 1px solid #444;
border-radius: 8px; color: #888; font-size: 0.9rem; cursor: pointer;
transition: border-color 0.2s, color 0.2s;
}
.btn-cancel:hover { border-color: #666; color: #ccc; }
/* Toast */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
background: #4a0000; color: #ffffff; padding: 10px 20px; border-radius: 8px;
font-size: 0.85rem; opacity: 0;
box-shadow: 0 0 16px rgba(74,0,0,0.7);
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none; z-index: 200;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { background: #5a1a1a; }
</style>
</head>
<body>
<nav>
<div class="logo">
<strong>MIKE</strong><span>BASE</span>
<span class="sub">MANAGE</span>
</div>
<a class="back" href="/">← Back to gallery</a>
</nav>
<div class="toolbar">
<div class="search-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" id="search" placeholder="Search by code or alias…" autocomplete="off">
</div>
<button class="add-btn" id="btn-add">+ Add Emote</button>
</div>
<div class="list">
<table id="table">
<thead>
<tr>
<th style="width:64px"></th>
<th>Code</th>
<th>Alias</th>
<th></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No emotes found.</div>
</div>
<!-- Add Modal -->
<div class="overlay" id="overlay-add">
<div class="modal">
<h2>Add Emote</h2>
<form id="form-add">
<div class="field">
<label>NAME <span style="color:#555">(used as :code:)</span></label>
<input type="text" id="add-name" required autocomplete="off">
</div>
<div class="field">
<label>ALIAS <span style="color:#555">(optional)</span></label>
<input type="text" id="add-alias" autocomplete="off">
</div>
<div class="field">
<label>IMAGE</label>
<input type="file" id="add-file" accept="image/*" required>
</div>
<div class="modal-actions">
<button type="button" class="btn-cancel" id="cancel-add">Cancel</button>
<button type="submit" class="btn-submit" id="submit-add">Upload</button>
</div>
</form>
</div>
</div>
<!-- Edit Modal -->
<div class="overlay" id="overlay-edit">
<div class="modal">
<h2>Edit Emote</h2>
<form id="form-edit">
<div class="field">
<label>NAME</label>
<input type="text" id="edit-name" required autocomplete="off">
</div>
<div class="field">
<label>ALIAS <span style="color:#555">(optional)</span></label>
<input type="text" id="edit-alias" autocomplete="off">
<div class="hint">To replace the image, delete this emote and re-add it.</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-cancel" id="cancel-edit">Cancel</button>
<button type="submit" class="btn-submit" id="submit-edit">Save</button>
</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function () {
var emotes = [];
var editUuid = null;
var toastTimer;
function showToast(msg, isErr) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast' + (isErr ? ' err' : '') + ' show';
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.className = 'toast' + (isErr ? ' err' : ''); }, 2200);
}
// --- Data ---
function load() {
fetch('/manage/emotes')
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) {
emotes = data.emotes || [];
render(document.getElementById('search').value.trim().toLowerCase());
})
.catch(function (e) { showToast('Failed to load: ' + e.message, true); });
}
// --- Render ---
function render(filter) {
var tbody = document.getElementById('tbody');
var empty = document.getElementById('empty');
tbody.innerHTML = '';
var list = filter
? emotes.filter(function (e) {
return e.name.toLowerCase().indexOf(filter) !== -1 ||
(e.alias && e.alias.toLowerCase().indexOf(filter) !== -1);
})
: emotes;
if (list.length === 0) {
empty.style.display = '';
document.getElementById('table').style.display = 'none';
return;
}
empty.style.display = 'none';
document.getElementById('table').style.display = '';
list.forEach(function (emote) {
var tr = document.createElement('tr');
tr.dataset.uuid = emote.uuid;
tr.innerHTML =
'<td class="td-img"><img src="' + esc(emote.url) + '" alt="' + esc(emote.name) + '" loading="lazy"></td>' +
'<td class="td-name"><span class="code">:' + esc(emote.name) + ':</span></td>' +
'<td class="td-alias">' + (emote.alias ? esc(emote.alias) : '<span style="color:#444">—</span>') + '</td>' +
'<td class="td-actions">' +
'<button class="btn-edit" data-uuid="' + esc(emote.uuid) + '" data-name="' + esc(emote.name) + '" data-alias="' + esc(emote.alias || '') + '">Edit</button>' +
'<button class="btn-del" data-uuid="' + esc(emote.uuid) + '">Delete</button>' +
'</td>';
tbody.appendChild(tr);
});
}
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// --- Search ---
document.getElementById('search').addEventListener('input', function () {
render(this.value.trim().toLowerCase());
});
// --- Add ---
document.getElementById('btn-add').addEventListener('click', function () {
document.getElementById('form-add').reset();
document.getElementById('overlay-add').classList.add('open');
document.getElementById('add-name').focus();
});
document.getElementById('cancel-add').addEventListener('click', function () {
document.getElementById('overlay-add').classList.remove('open');
});
document.getElementById('overlay-add').addEventListener('click', function (e) {
if (e.target === this) this.classList.remove('open');
});
document.getElementById('form-add').addEventListener('submit', function (e) {
e.preventDefault();
var name = document.getElementById('add-name').value.trim();
var alias = document.getElementById('add-alias').value.trim();
var file = document.getElementById('add-file').files[0];
if (!name || !file) return;
var btn = document.getElementById('submit-add');
btn.disabled = true;
btn.textContent = 'Uploading…';
var fd = new FormData();
fd.append('name', name);
if (alias) fd.append('alias', alias);
fd.append('file', file);
fetch('/emotes', { method: 'POST', body: fd })
.then(function (r) {
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'HTTP ' + r.status); });
return r.json();
})
.then(function () {
document.getElementById('overlay-add').classList.remove('open');
showToast('Emote added');
load();
})
.catch(function (err) { showToast(err.message, true); })
.finally(function () { btn.disabled = false; btn.textContent = 'Upload'; });
});
// --- Edit ---
document.getElementById('cancel-edit').addEventListener('click', function () {
document.getElementById('overlay-edit').classList.remove('open');
});
document.getElementById('overlay-edit').addEventListener('click', function (e) {
if (e.target === this) this.classList.remove('open');
});
document.getElementById('tbody').addEventListener('click', function (e) {
var editBtn = e.target.closest('.btn-edit');
if (editBtn) {
editUuid = editBtn.dataset.uuid;
document.getElementById('edit-name').value = editBtn.dataset.name;
document.getElementById('edit-alias').value = editBtn.dataset.alias;
document.getElementById('overlay-edit').classList.add('open');
document.getElementById('edit-name').focus();
return;
}
var delBtn = e.target.closest('.btn-del');
if (delBtn) {
var uuid = delBtn.dataset.uuid;
var td = delBtn.closest('td');
td.innerHTML =
'<button class="btn-del-confirm" data-uuid="' + esc(uuid) + '">Confirm delete</button>' +
'<button class="btn-cancel-del">Cancel</button>';
return;
}
var confirmBtn = e.target.closest('.btn-del-confirm');
if (confirmBtn) {
var uuid = confirmBtn.dataset.uuid;
fetch('/emotes/' + encodeURIComponent(uuid), { method: 'DELETE' })
.then(function (r) {
if (!r.ok && r.status !== 204)
return r.json().then(function (j) { throw new Error(j.error || 'HTTP ' + r.status); });
})
.then(function () { showToast('Emote deleted'); load(); })
.catch(function (err) { showToast(err.message, true); load(); });
return;
}
var cancelDelBtn = e.target.closest('.btn-cancel-del');
if (cancelDelBtn) {
var tr = cancelDelBtn.closest('tr');
var uuid = tr.dataset.uuid;
var emote = emotes.find(function (em) { return em.uuid === uuid; });
if (emote) {
cancelDelBtn.closest('td').innerHTML =
'<button class="btn-edit" data-uuid="' + esc(emote.uuid) + '" data-name="' + esc(emote.name) + '" data-alias="' + esc(emote.alias || '') + '">Edit</button>' +
'<button class="btn-del" data-uuid="' + esc(emote.uuid) + '">Delete</button>';
}
}
});
document.getElementById('form-edit').addEventListener('submit', function (e) {
e.preventDefault();
if (!editUuid) return;
var name = document.getElementById('edit-name').value.trim();
var alias = document.getElementById('edit-alias').value.trim();
if (!name) return;
var btn = document.getElementById('submit-edit');
btn.disabled = true;
btn.textContent = 'Saving…';
var body = { name: name, alias: alias || null };
fetch('/emotes/' + encodeURIComponent(editUuid), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(function (r) {
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'HTTP ' + r.status); });
return r.json();
})
.then(function () {
document.getElementById('overlay-edit').classList.remove('open');
showToast('Emote updated');
load();
})
.catch(function (err) { showToast(err.message, true); })
.finally(function () { btn.disabled = false; btn.textContent = 'Save'; });
});
load();
})();
</script>
</body>
</html>
+363
View File
@@ -0,0 +1,363 @@
use std::sync::Arc;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use base64::{engine::general_purpose::STANDARD, Engine};
use sqlx::any::install_default_drivers;
use tower::ServiceExt;
use mikebase::{
build_router,
config::{AppConfig, AuthConfig, DatabaseConfig, S3Config, ServerConfig},
db::Database,
models::new_uuid,
storage::S3Storage,
AppState,
};
// ── Helpers ───────────────────────────────────────────────────────────────────
async fn test_state() -> AppState {
install_default_drivers();
let pool = sqlx::pool::PoolOptions::<sqlx::Any>::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let db = Database { pool };
db.migrate().await.unwrap();
let cfg = Arc::new(AppConfig {
s3: S3Config {
endpoint: "http://localhost:19999".to_string(),
region: "us-east-1".to_string(),
bucket: "test-bucket".to_string(),
access_key: "test".to_string(),
secret_key: "test".to_string(),
public_url: "http://localhost:19999/test-bucket".to_string(),
},
database: DatabaseConfig { url: "sqlite::memory:".to_string() },
server: ServerConfig::default(),
auth: Some(AuthConfig {
username: "admin".to_string(),
password: "secret".to_string(),
}),
});
let storage = S3Storage::new(&cfg);
AppState { db, storage, cfg }
}
fn auth_header() -> String {
format!("Basic {}", STANDARD.encode("admin:secret"))
}
fn wrong_auth_header() -> String {
format!("Basic {}", STANDARD.encode("admin:wrong"))
}
async fn response_json(body: Body) -> serde_json::Value {
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
/// Build a minimal multipart/form-data body.
fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &[u8])]) -> Vec<u8> {
let mut body = Vec::new();
for (name, filename, data) in parts {
let disp = match filename {
Some(fname) => format!(
"Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\nContent-Type: application/octet-stream"
),
None => format!("Content-Disposition: form-data; name=\"{name}\""),
};
body.extend_from_slice(format!("--{boundary}\r\n{disp}\r\n\r\n").as_bytes());
body.extend_from_slice(data);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
body
}
// ── Public routes ─────────────────────────────────────────────────────────────
#[tokio::test]
async fn health_returns_200() {
let app = build_router(test_state().await);
let resp = app
.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = response_json(resp.into_body()).await;
assert_eq!(json["status"], "ok");
}
#[tokio::test]
async fn root_returns_html() {
let app = build_router(test_state().await);
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.contains("text/html"));
}
#[tokio::test]
async fn list_emotes_empty() {
let app = build_router(test_state().await);
let resp = app
.oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = response_json(resp.into_body()).await;
assert_eq!(json["emotes"], serde_json::json!([]));
}
#[tokio::test]
async fn list_emotes_returns_seeded_data() {
let state = test_state().await;
let id = new_uuid();
state
.db
.create_emote(&id, "wave", Some("hello"), "emoji/wave.png")
.await
.unwrap();
let resp = build_router(state)
.oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = response_json(resp.into_body()).await;
assert_eq!(json["emotes"].as_array().unwrap().len(), 1);
assert_eq!(json["emotes"][0]["name"], "wave");
}
// ── Auth middleware ───────────────────────────────────────────────────────────
#[tokio::test]
async fn manage_without_credentials_returns_401() {
let app = build_router(test_state().await);
let resp = app
.oneshot(Request::builder().uri("/manage").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn manage_with_wrong_credentials_returns_401() {
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.uri("/manage")
.header("authorization", wrong_auth_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn manage_with_correct_credentials_returns_200() {
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.uri("/manage")
.header("authorization", auth_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn manage_emotes_requires_auth() {
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.uri("/manage/emotes")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// ── POST /emotes input validation ─────────────────────────────────────────────
#[tokio::test]
async fn create_emote_without_auth_returns_401() {
let boundary = "testboundary";
let body = multipart_body(boundary, &[("name", None, b"myemote")]);
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/emotes")
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn create_emote_missing_name_returns_400() {
let boundary = "testboundary";
let body = multipart_body(boundary, &[("file", Some("cat.png"), b"\x89PNG")]);
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/emotes")
.header("authorization", auth_header())
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = response_json(resp.into_body()).await;
assert!(json["error"].as_str().unwrap().contains("name"));
}
#[tokio::test]
async fn create_emote_missing_file_returns_400() {
let boundary = "testboundary";
let body = multipart_body(boundary, &[("name", None, b"myemote")]);
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/emotes")
.header("authorization", auth_header())
.header("content-type", format!("multipart/form-data; boundary={boundary}"))
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = response_json(resp.into_body()).await;
assert!(json["error"].as_str().unwrap().contains("file"));
}
// ── PUT /emotes/{uuid} ────────────────────────────────────────────────────────
#[tokio::test]
async fn update_emote_not_found_returns_404() {
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/emotes/no-such-uuid")
.header("authorization", auth_header())
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"new"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn update_emote_ok() {
let state = test_state().await;
let id = new_uuid();
state
.db
.create_emote(&id, "original", None, "emoji/x.png")
.await
.unwrap();
let resp = build_router(state)
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/emotes/{id}"))
.header("authorization", auth_header())
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"updated"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = response_json(resp.into_body()).await;
assert_eq!(json["name"], "updated");
}
// ── DELETE /emotes/{uuid} ─────────────────────────────────────────────────────
#[tokio::test]
async fn delete_emote_not_found_returns_404() {
let app = build_router(test_state().await);
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/emotes/no-such-uuid")
.header("authorization", auth_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_emote_ok() {
let state = test_state().await;
let id = new_uuid();
state
.db
.create_emote(&id, "todelete", None, "emoji/gone.png")
.await
.unwrap();
// S3 delete will fail against the fake endpoint, but it is best-effort.
let resp = build_router(state)
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/emotes/{id}"))
.header("authorization", auth_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}