Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e230720581 | |||
| d1755fd481 | |||
| 4fb17abb71 | |||
| fd843535e6 | |||
| ad0db1c5c2 |
@@ -1,125 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: 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
|
|
||||||
run: |
|
|
||||||
cp target/x86_64-unknown-linux-gnu/release/mikebase \
|
|
||||||
mikebase-${{ github.ref_name }}-x86_64-linux
|
|
||||||
|
|
||||||
- name: Create Gitea release and upload binary
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITEA_URL: ${{ github.server_url }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
# Gitea may not have finished processing the tag ref by the time the
|
|
||||||
# workflow runner starts, so retry a few times before giving up.
|
|
||||||
RELEASE_ID=""
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
-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)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -1)
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" -eq 201 ]; then
|
|
||||||
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
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Upload binary asset
|
|
||||||
BINARY="mikebase-${TAG}-x86_64-linux"
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${BINARY}" \
|
|
||||||
--data-binary @"${BINARY}"
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: Build and push Docker image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Log in to Gitea container registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.open3dlab.com
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: git.open3dlab.com/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
@@ -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
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# 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.4.0] - 2026-05-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `POST /manage/import` — import emotes from a legacy JSON endpoint, downloading
|
|
||||||
each image and mirroring it to S3 with original timestamps preserved.
|
|
||||||
- Duplicate detection: emotes whose name already exists are skipped (compatible
|
|
||||||
with both SQLite and PostgreSQL).
|
|
||||||
- Host allowlist for import source URLs, configurable via `[import]
|
|
||||||
allowed_hosts` in `config.toml` (default: `["smutba.se"]`).
|
|
||||||
- Dry-run mode (`"dry_run": true` in request body): previews the import without
|
|
||||||
writing to S3 or the database. Result statuses are `"would_import"` and
|
|
||||||
`"would_skip"` instead of `"imported"` and `"skipped"`.
|
|
||||||
|
|
||||||
## [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.4.0...HEAD
|
|
||||||
[0.4.0]: https://git.open3dlab.com/Open3DLab/mikebase/compare/v0.3.0...v0.4.0
|
|
||||||
[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
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
cargo build
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# Run (requires config.toml or APP__ env vars)
|
|
||||||
cargo run
|
|
||||||
|
|
||||||
# Check without building
|
|
||||||
cargo check
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Run a single test
|
|
||||||
cargo test <test_name>
|
|
||||||
|
|
||||||
# Lint
|
|
||||||
cargo clippy
|
|
||||||
|
|
||||||
# Format
|
|
||||||
cargo fmt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Copy `config.example.toml` to `config.toml` and fill in the values. All config fields can be overridden with `APP__` prefixed environment variables using `__` as separator (e.g. `APP__DATABASE__URL`, `APP__S3__BUCKET`).
|
|
||||||
|
|
||||||
Supports both SQLite (`sqlite://mikebase.db`) and PostgreSQL (`postgresql://user:pass@host/db`) via `sqlx::AnyPool`.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The app is an Axum HTTP server with shared state (`AppState`) containing two components:
|
|
||||||
- **`Database`** (`src/db.rs`): Thin wrapper around `sqlx::AnyPool` supporting SQLite and PostgreSQL. Runs migrations from `migrations/` on startup. All timestamps are stored as ISO 8601 strings (not native DB types) because `sqlx::Any` lacks a blanket chrono `Encode`/`Decode` impl.
|
|
||||||
- **`S3Storage`** (`src/storage.rs`): AWS SDK S3 client pointed at a configurable endpoint (Wasabi by default). Images are uploaded under the key `emoji/<filename>` and public URLs are built from the configured `public_url` base.
|
|
||||||
|
|
||||||
**Data flow for emote creation:** multipart form → upload bytes to S3 → insert row into DB → return `EmoteResponse` JSON.
|
|
||||||
|
|
||||||
**Models** (`src/models.rs`):
|
|
||||||
- `EmoteRow` — raw DB row (timestamps as strings)
|
|
||||||
- `EmoteResponse` — JSON API response (timestamps as `DateTime<Utc>`)
|
|
||||||
|
|
||||||
**Routes** (`src/routes/`):
|
|
||||||
- `GET /` — health check
|
|
||||||
- `GET /version` — git commit hash and tag (baked in at build time via `build.rs`)
|
|
||||||
- `GET /json` — list all emotes
|
|
||||||
- `POST /emotes` — create emote (multipart: `name`, `alias?`, `file`)
|
|
||||||
- `PUT /emotes/{uuid}` — update emote metadata (JSON: `name?`, `alias?`, `image_key?`)
|
|
||||||
- `DELETE /emotes/{uuid}` — delete emote from DB and S3 (S3 delete is best-effort)
|
|
||||||
|
|
||||||
## Releases
|
|
||||||
|
|
||||||
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
-181
@@ -703,12 +703,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg_aliases"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -1220,10 +1214,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1233,11 +1225,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1530,7 +1520,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1706,16 +1695,6 @@ version = "2.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iri-string"
|
|
||||||
version = "0.7.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1833,12 +1812,6 @@ dependencies = [
|
|||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru-slab"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1878,23 +1851,19 @@ 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",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"reqwest",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"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",
|
||||||
"url",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2249,61 +2218,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"cfg_aliases",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn-proto",
|
|
||||||
"quinn-udp",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls 0.23.37",
|
|
||||||
"socket2 0.6.3",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-proto"
|
|
||||||
version = "0.11.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"lru-slab",
|
|
||||||
"rand 0.9.2",
|
|
||||||
"ring",
|
|
||||||
"rustc-hash",
|
|
||||||
"rustls 0.23.37",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"slab",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tinyvec",
|
|
||||||
"tracing",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quinn-udp"
|
|
||||||
version = "0.5.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
|
||||||
dependencies = [
|
|
||||||
"cfg_aliases",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"socket2 0.6.3",
|
|
||||||
"tracing",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -2437,44 +2351,6 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "reqwest"
|
|
||||||
version = "0.12.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"bytes",
|
|
||||||
"futures-core",
|
|
||||||
"http 1.4.0",
|
|
||||||
"http-body 1.0.1",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper 1.8.1",
|
|
||||||
"hyper-rustls 0.27.7",
|
|
||||||
"hyper-util",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn",
|
|
||||||
"rustls 0.23.37",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls 0.26.4",
|
|
||||||
"tower",
|
|
||||||
"tower-http",
|
|
||||||
"tower-service",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
"webpki-roots",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rfc6979"
|
name = "rfc6979"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2542,12 +2418,6 @@ dependencies = [
|
|||||||
"ordered-multimap",
|
"ordered-multimap",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-hash"
|
|
||||||
version = "2.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2577,7 +2447,6 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki 0.103.9",
|
"rustls-webpki 0.103.9",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -2602,7 +2471,6 @@ version = "1.14.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3149,9 +3017,6 @@ name = "sync_wrapper"
|
|||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
@@ -3414,12 +3279,9 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"iri-string",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower",
|
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3680,20 +3542,6 @@ dependencies = [
|
|||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-futures"
|
|
||||||
version = "0.4.64"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"futures-util",
|
|
||||||
"js-sys",
|
|
||||||
"once_cell",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.114"
|
version = "0.2.114"
|
||||||
@@ -3760,35 +3608,6 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-sys"
|
|
||||||
version = "0.3.91"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web-time"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "1.0.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
|
|||||||
@@ -22,9 +22,3 @@ 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"
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
|
||||||
url = "2"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tower = { version = "0.5", features = ["util"] }
|
|
||||||
|
|||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
|
||||||
FROM rust:1-slim AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Cache dependency compilation separately from application code.
|
|
||||||
COPY Cargo.toml Cargo.lock build.rs ./
|
|
||||||
# Dummy source so `cargo build` can compile dependencies without the real code.
|
|
||||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
|
||||||
&& cargo build --release \
|
|
||||||
&& rm src/main.rs
|
|
||||||
|
|
||||||
# Build the real application.
|
|
||||||
COPY src ./src
|
|
||||||
COPY migrations ./migrations
|
|
||||||
# Touch main.rs so Cargo detects the change and recompiles.
|
|
||||||
RUN touch src/main.rs \
|
|
||||||
&& cargo build --release
|
|
||||||
|
|
||||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /build/target/release/mikebase ./mikebase
|
|
||||||
COPY migrations ./migrations
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:3000/health || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["./mikebase"]
|
|
||||||
@@ -1,57 +1,2 @@
|
|||||||
# mikebase
|
# mikebase
|
||||||
A Rust-based emote database and API.
|
A Rust-based emote database and API.
|
||||||
|
|
||||||
## Importing legacy emotes
|
|
||||||
|
|
||||||
Use the protected management endpoint to mirror emotes from a legacy JSON feed.
|
|
||||||
|
|
||||||
### Endpoint
|
|
||||||
|
|
||||||
POST /manage/import
|
|
||||||
|
|
||||||
- Auth: HTTP Basic (same credentials used for other protected routes)
|
|
||||||
- Content-Type: application/json
|
|
||||||
- Body:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source_url": "https://smutba.se/emoji/json/"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Behavior
|
|
||||||
|
|
||||||
- Fetches source JSON in the format `{"emotes": [{name, url, created, modified}, ...]}`
|
|
||||||
- Downloads each image URL and uploads bytes to this app's configured S3 bucket
|
|
||||||
- Inserts emote rows preserving source `created` and `modified` timestamps
|
|
||||||
- Skips entries where `name` already exists locally
|
|
||||||
- Continues processing after per-item failures and returns a batch summary
|
|
||||||
|
|
||||||
### Example response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source_url": "https://smutba.se/emoji/json/",
|
|
||||||
"total": 2,
|
|
||||||
"imported": 1,
|
|
||||||
"skipped": 1,
|
|
||||||
"failed": 0,
|
|
||||||
"results": [
|
|
||||||
{"name": "legacy_new", "status": "imported", "reason": null},
|
|
||||||
{"name": "legacy_duplicate", "status": "skipped", "reason": "Name already exists"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Allowlisted hosts
|
|
||||||
|
|
||||||
Import is restricted to hosts in configuration:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[import]
|
|
||||||
allowed_hosts = ["smutba.se"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment override example:
|
|
||||||
|
|
||||||
- `APP__IMPORT__ALLOWED_HOSTS=["smutba.se","legacy.example.org"]`
|
|
||||||
|
|||||||
@@ -12,18 +12,6 @@ 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"
|
|
||||||
|
|
||||||
[import]
|
|
||||||
# Hosts allowed as migration sources for POST /manage/import.
|
|
||||||
# The request is rejected unless the source URL host matches one of these.
|
|
||||||
allowed_hosts = ["smutba.se"]
|
|
||||||
|
|
||||||
[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
@@ -1,140 +0,0 @@
|
|||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,31 +37,12 @@ fn default_port() -> u16 {
|
|||||||
3000
|
3000
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct AuthConfig {
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct ImportConfig {
|
|
||||||
#[serde(default = "default_allowed_hosts")]
|
|
||||||
pub allowed_hosts: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_allowed_hosts() -> Vec<String> {
|
|
||||||
vec!["smutba.se".to_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>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub import: ImportConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -73,14 +54,6 @@ impl Default for ServerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ImportConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
allowed_hosts: default_allowed_hosts(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> Result<Self, ConfigError> {
|
pub fn load() -> Result<Self, ConfigError> {
|
||||||
let cfg = Config::builder()
|
let cfg = Config::builder()
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
|
|
||||||
use crate::models::EmoteRow;
|
use crate::{
|
||||||
|
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(url: &str) -> Result<Self, sqlx::Error> {
|
pub async fn connect(config: Arc<AppConfig>) -> Result<Self, sqlx::Error> {
|
||||||
let pool = AnyPool::connect(url).await?;
|
let pool = AnyPool::connect(&config.database.url).await?;
|
||||||
Ok(Self { pool })
|
Ok(Self { pool, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run pending migrations.
|
/// Run pending migrations.
|
||||||
@@ -40,26 +46,18 @@ impl Database {
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn name_exists(&self, name: &str) -> Result<bool, sqlx::Error> {
|
|
||||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM emotes WHERE name = $1")
|
|
||||||
.bind(name)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.0 > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(uuid)
|
.bind(&id)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(alias)
|
.bind(alias)
|
||||||
.bind(image_key)
|
.bind(image_key)
|
||||||
@@ -69,7 +67,7 @@ impl Database {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(EmoteRow {
|
Ok(EmoteRow {
|
||||||
uuid: uuid.to_string(),
|
uuid: id,
|
||||||
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(),
|
||||||
@@ -78,37 +76,6 @@ impl Database {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_emote_with_timestamps(
|
|
||||||
&self,
|
|
||||||
uuid: &str,
|
|
||||||
name: &str,
|
|
||||||
alias: Option<&str>,
|
|
||||||
image_key: &str,
|
|
||||||
created: &str,
|
|
||||||
modified: &str,
|
|
||||||
) -> Result<EmoteRow, sqlx::Error> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO emotes (uuid, name, alias, image_key, created, modified) VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(uuid)
|
|
||||||
.bind(name)
|
|
||||||
.bind(alias)
|
|
||||||
.bind(image_key)
|
|
||||||
.bind(created)
|
|
||||||
.bind(modified)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(EmoteRow {
|
|
||||||
uuid: uuid.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
alias: alias.map(|s| s.to_string()),
|
|
||||||
image_key: image_key.to_string(),
|
|
||||||
created: created.to_string(),
|
|
||||||
modified: modified.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_emote(
|
pub async fn update_emote(
|
||||||
&self,
|
&self,
|
||||||
uuid: &str,
|
uuid: &str,
|
||||||
@@ -116,32 +83,39 @@ 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 now = Utc::now().to_rfc3339();
|
let existing = match self.get_emote_by_id(uuid).await? {
|
||||||
// alias has three states: None = keep, Some(None) = clear, Some(Some(v)) = set.
|
Some(e) => e,
|
||||||
// Pass a boolean flag so CASE WHEN can choose between the new value and the
|
None => return Ok(None),
|
||||||
// existing column — all resolved atomically in a single statement.
|
};
|
||||||
let alias_touch = alias.is_some();
|
|
||||||
let alias_value: Option<&str> = alias.flatten();
|
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, EmoteRow>(
|
let new_name = name.unwrap_or(&existing.name);
|
||||||
"UPDATE emotes
|
let new_image_key = image_key.unwrap_or(&existing.image_key);
|
||||||
SET name = COALESCE($1, name),
|
let new_alias: Option<String> = match alias {
|
||||||
alias = CASE WHEN $2 THEN $3 ELSE alias END,
|
Some(Some(a)) => Some(a.to_string()),
|
||||||
image_key = COALESCE($4, image_key),
|
Some(None) => None,
|
||||||
modified = $5
|
None => existing.alias.clone(),
|
||||||
WHERE uuid = $6
|
};
|
||||||
RETURNING uuid, name, alias, image_key, created, modified",
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE emotes SET name = $1, alias = $2, image_key = $3, modified = $4 WHERE uuid = $5",
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(new_name)
|
||||||
.bind(alias_touch)
|
.bind(new_alias.as_deref())
|
||||||
.bind(alias_value)
|
.bind(new_image_key)
|
||||||
.bind(image_key)
|
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(uuid)
|
.bind(uuid)
|
||||||
.fetch_optional(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(row)
|
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> {
|
pub async fn delete_emote(&self, uuid: &str) -> Result<bool, sqlx::Error> {
|
||||||
@@ -152,149 +126,3 @@ 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 create_with_preserved_timestamps() {
|
|
||||||
let db = test_db().await;
|
|
||||||
let id = new_uuid();
|
|
||||||
let created = "2020-01-01T10:00:00+00:00";
|
|
||||||
let modified = "2021-02-03T11:30:00+00:00";
|
|
||||||
|
|
||||||
let row = db
|
|
||||||
.create_emote_with_timestamps(&id, "legacy", None, "emoji/legacy.png", created, modified)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(row.created, created);
|
|
||||||
assert_eq!(row.modified, modified);
|
|
||||||
|
|
||||||
let fetched = db.get_emote_by_id(&id).await.unwrap().unwrap();
|
|
||||||
assert_eq!(fetched.created, created);
|
|
||||||
assert_eq!(fetched.modified, modified);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
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("/manage/import", post(routes::manage::import_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)
|
|
||||||
}
|
|
||||||
+33
-4
@@ -1,9 +1,28 @@
|
|||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod models;
|
||||||
|
mod routes;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use mikebase::{config::AppConfig, db::Database, storage::S3Storage, AppState, build_router};
|
use axum::{
|
||||||
|
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.
|
||||||
@@ -23,7 +42,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.database.url)
|
let db = Database::connect(cfg.clone())
|
||||||
.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");
|
||||||
@@ -31,14 +50,24 @@ 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, cfg: cfg.clone() };
|
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);
|
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, build_router(state))
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.expect("Server error");
|
.expect("Server error");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-125
@@ -40,138 +40,14 @@ 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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ImportEmotesRequest {
|
|
||||||
pub source_url: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub dry_run: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct LegacyEmotesPayload {
|
|
||||||
pub emotes: Vec<LegacyEmote>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct LegacyEmote {
|
|
||||||
pub name: String,
|
|
||||||
pub url: String,
|
|
||||||
pub created: DateTime<Utc>,
|
|
||||||
pub modified: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ImportEmotesResponse {
|
|
||||||
pub source_url: String,
|
|
||||||
pub dry_run: bool,
|
|
||||||
pub total: usize,
|
|
||||||
pub imported: usize,
|
|
||||||
pub skipped: usize,
|
|
||||||
pub failed: usize,
|
|
||||||
pub results: Vec<ImportEmoteResult>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ImportEmoteResult {
|
|
||||||
pub name: String,
|
|
||||||
pub status: String,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
||||||
#[serde(default, deserialize_with = "deserialize_optional_field")]
|
pub alias: Option<String>,
|
||||||
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())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-13
@@ -1,19 +1,19 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Multipart, Path, State},
|
extract::{Multipart, Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{Html, IntoResponse, Json},
|
response::{IntoResponse, Json},
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{new_uuid, EmoteResponse, UpdateEmoteRequest},
|
models::{EmoteResponse, UpdateEmoteRequest},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// GET /
|
/// GET /
|
||||||
/// Serves an HTML page that dynamically loads and displays emotes from /json.
|
/// Returns a simple health-check message.
|
||||||
pub async fn root() -> impl IntoResponse {
|
pub async fn root() -> impl IntoResponse {
|
||||||
Html(include_str!("../templates/index.html"))
|
Json(json!({"status": "ok", "message": "mikebase server is running"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /json
|
/// GET /json
|
||||||
@@ -100,13 +100,7 @@ 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 id = new_uuid();
|
let key = format!("emoji/{fname}");
|
||||||
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(_) => {}
|
||||||
@@ -122,7 +116,7 @@ pub async fn create_emote(
|
|||||||
|
|
||||||
match state
|
match state
|
||||||
.db
|
.db
|
||||||
.create_emote(&id, &name, alias.as_deref(), &key)
|
.create_emote(&name, alias.as_deref(), &key)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(row) => {
|
Ok(row) => {
|
||||||
@@ -156,7 +150,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(|opt| opt.as_deref());
|
let alias_update: Option<Option<&str>> = payload.alias.as_ref().map(|a| Some(a.as_str()));
|
||||||
|
|
||||||
match state
|
match state
|
||||||
.db
|
.db
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
use axum::response::{IntoResponse, Json};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
/// GET /health
|
|
||||||
/// Returns a simple health-check response for liveness probes.
|
|
||||||
pub async fn health() -> impl IntoResponse {
|
|
||||||
Json(json!({"status": "ok"}))
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
http::StatusCode,
|
|
||||||
response::{Html, IntoResponse, Json},
|
|
||||||
Json as AxumJson,
|
|
||||||
};
|
|
||||||
use reqwest::header::CONTENT_TYPE;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::Duration;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
models::{
|
|
||||||
new_uuid, AdminEmoteResponse, ImportEmoteResult, ImportEmotesRequest, ImportEmotesResponse,
|
|
||||||
LegacyEmotesPayload,
|
|
||||||
},
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /manage/import
|
|
||||||
/// Import emotes from a legacy JSON endpoint and mirror image bytes to local storage.
|
|
||||||
/// When `dry_run` is true, validates and previews the import without writing anything.
|
|
||||||
pub async fn import_emotes(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumJson(payload): AxumJson<ImportEmotesRequest>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let dry_run = payload.dry_run;
|
|
||||||
let source_url = payload.source_url.trim();
|
|
||||||
if source_url.is_empty() {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(json!({"error": "source_url is required"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed_source = match Url::parse(source_url) {
|
|
||||||
Ok(url) => url,
|
|
||||||
Err(_) => {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(json!({"error": "Invalid source_url"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !is_host_allowed(parsed_source.host_str(), &state.cfg.import.allowed_hosts) {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(json!({"error": "source_url host is not allowlisted"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = match reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(10))
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to build HTTP client: {e}");
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"error": "Failed to initialize import client"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let source_resp = match client.get(parsed_source.clone()).send().await {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to fetch source payload: {e}");
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(json!({"error": "Failed to fetch source payload"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !source_resp.status().is_success() {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(json!({
|
|
||||||
"error": "Source payload request failed",
|
|
||||||
"status": source_resp.status().as_u16()
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: LegacyEmotesPayload = match source_resp.json().await {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to parse source payload JSON: {e}");
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(json!({"error": "Invalid source payload schema"})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let total = payload.emotes.len();
|
|
||||||
let mut imported = 0usize;
|
|
||||||
let mut skipped = 0usize;
|
|
||||||
let mut failed = 0usize;
|
|
||||||
let mut results = Vec::with_capacity(total);
|
|
||||||
|
|
||||||
for legacy in payload.emotes {
|
|
||||||
let name = legacy.name.trim().to_string();
|
|
||||||
if name.is_empty() {
|
|
||||||
failed += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name: legacy.name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some("Missing emote name".to_string()),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let image_url = match Url::parse(&legacy.url) {
|
|
||||||
Ok(url) => url,
|
|
||||||
Err(_) => {
|
|
||||||
failed += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some("Invalid image URL".to_string()),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let image_resp = match client.get(image_url.clone()).send().await {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(e) => {
|
|
||||||
failed += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some(format!("Image download failed: {e}")),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !image_resp.status().is_success() {
|
|
||||||
failed += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some(format!("Image download failed with status {}", image_resp.status())),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_type = image_resp
|
|
||||||
.headers()
|
|
||||||
.get(CONTENT_TYPE)
|
|
||||||
.and_then(|h| h.to_str().ok())
|
|
||||||
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
mime_guess::from_path(image_url.path())
|
|
||||||
.first_or_octet_stream()
|
|
||||||
.to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
let data = match image_resp.bytes().await {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
failed += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some(format!("Failed reading image bytes: {e}")),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if dry_run {
|
|
||||||
// Check for a name collision without writing anything.
|
|
||||||
let exists = match state.db.name_exists(&name).await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
failed += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some(format!("DB check failed: {e}")),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if exists {
|
|
||||||
skipped += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "would_skip".to_string(),
|
|
||||||
reason: Some("Name already exists".to_string()),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
imported += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "would_import".to_string(),
|
|
||||||
reason: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ext = infer_extension(&image_url, &content_type);
|
|
||||||
let id = new_uuid();
|
|
||||||
let key = format!("emoji/{id}.{ext}");
|
|
||||||
|
|
||||||
if let Err(e) = state.storage.upload(&key, data, &content_type).await {
|
|
||||||
failed += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some(format!("Storage upload failed: {e}")),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let created = legacy.created.to_rfc3339();
|
|
||||||
let modified = legacy.modified.to_rfc3339();
|
|
||||||
match state
|
|
||||||
.db
|
|
||||||
.create_emote_with_timestamps(&id, &name, None, &key, &created, &modified)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
imported += 1;
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "imported".to_string(),
|
|
||||||
reason: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) if is_unique_name_violation(&e) => {
|
|
||||||
skipped += 1;
|
|
||||||
if let Err(del_err) = state.storage.delete(&key).await {
|
|
||||||
tracing::warn!("Failed to delete skipped upload key {key}: {del_err}");
|
|
||||||
}
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "skipped".to_string(),
|
|
||||||
reason: Some("Name already exists".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
failed += 1;
|
|
||||||
if let Err(del_err) = state.storage.delete(&key).await {
|
|
||||||
tracing::warn!("Failed to cleanup failed upload key {key}: {del_err}");
|
|
||||||
}
|
|
||||||
results.push(ImportEmoteResult {
|
|
||||||
name,
|
|
||||||
status: "failed".to_string(),
|
|
||||||
reason: Some(format!("Database insert failed: {e}")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = ImportEmotesResponse {
|
|
||||||
source_url: parsed_source.to_string(),
|
|
||||||
dry_run,
|
|
||||||
total,
|
|
||||||
imported,
|
|
||||||
skipped,
|
|
||||||
failed,
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
|
|
||||||
(StatusCode::OK, Json(json!(response))).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_host_allowed(host: Option<&str>, allowed_hosts: &[String]) -> bool {
|
|
||||||
let Some(host) = host else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let host = host.to_ascii_lowercase();
|
|
||||||
allowed_hosts
|
|
||||||
.iter()
|
|
||||||
.any(|allowed| allowed.eq_ignore_ascii_case(&host))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn infer_extension(url: &Url, content_type: &str) -> String {
|
|
||||||
if let Some(ext) = std::path::Path::new(url.path())
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.filter(|e| !e.is_empty())
|
|
||||||
{
|
|
||||||
let lower = ext.to_ascii_lowercase();
|
|
||||||
if lower.chars().all(|c| c.is_ascii_alphanumeric()) {
|
|
||||||
return lower;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(mime) = mime_guess::get_mime_extensions_str(content_type)
|
|
||||||
.and_then(|exts| exts.first())
|
|
||||||
{
|
|
||||||
return (*mime).to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
"bin".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_unique_name_violation(err: &sqlx::Error) -> bool {
|
|
||||||
match err {
|
|
||||||
sqlx::Error::Database(db_err) => {
|
|
||||||
if let Some(code) = db_err.code() {
|
|
||||||
if code == "23505" || code == "2067" || code == "1555" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = db_err.message().to_ascii_lowercase();
|
|
||||||
msg.contains("unique") || msg.contains("duplicate")
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
pub mod emotes;
|
pub mod emotes;
|
||||||
pub mod health;
|
|
||||||
pub mod manage;
|
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>MIKEBASE</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; 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; }
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.grid { display: flex; flex-wrap: wrap; gap: 14px; padding: 24px; justify-content: center; }
|
|
||||||
.emote {
|
|
||||||
display: flex; flex-direction: column; align-items: center;
|
|
||||||
cursor: pointer; padding: 10px; border-radius: 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease, transform 0.2s ease;
|
|
||||||
animation: fadeInUp 0.4s ease both;
|
|
||||||
}
|
|
||||||
.emote:hover {
|
|
||||||
background: rgba(74, 0, 0, 0.15);
|
|
||||||
border-color: #4a0000;
|
|
||||||
box-shadow: 0 0 14px rgba(74, 0, 0, 0.6);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
.emote:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
box-shadow: 0 0 20px rgba(74, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
.emote img {
|
|
||||||
width: 64px; height: 64px; object-fit: contain;
|
|
||||||
transition: filter 0.25s ease, transform 0.25s ease;
|
|
||||||
}
|
|
||||||
.emote:hover img {
|
|
||||||
filter: drop-shadow(0 0 6px rgba(74, 0, 0, 0.7));
|
|
||||||
transform: scale(1.08);
|
|
||||||
}
|
|
||||||
.emote .code {
|
|
||||||
font-size: 0.7rem; margin-top: 6px; color: #999;
|
|
||||||
max-width: 80px; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
white-space: nowrap; text-align: center;
|
|
||||||
transition: color 0.25s ease;
|
|
||||||
}
|
|
||||||
.emote:hover .code { color: #ff4d4d; }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav>
|
|
||||||
<div class="logo"><strong>MIKE</strong><span>BASE</span></div>
|
|
||||||
</nav>
|
|
||||||
<div class="grid" id="grid"></div>
|
|
||||||
<div class="toast" id="toast">Copied!</div>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
var toast = document.getElementById('toast');
|
|
||||||
var timer;
|
|
||||||
|
|
||||||
function showToast(text) {
|
|
||||||
toast.textContent = text;
|
|
||||||
toast.classList.add('show');
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(function(){ toast.classList.remove('show'); }, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyCode(code) {
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(code).then(function(){
|
|
||||||
showToast('Copied ' + code);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
var ta = document.createElement('textarea');
|
|
||||||
ta.value = code;
|
|
||||||
ta.style.position = 'fixed';
|
|
||||||
ta.style.opacity = '0';
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
showToast('Copied ' + code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/json')
|
|
||||||
.then(function(r){
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
return r.json();
|
|
||||||
})
|
|
||||||
.then(function(data){
|
|
||||||
var grid = document.getElementById('grid');
|
|
||||||
var emotes = data.emotes || [];
|
|
||||||
emotes.forEach(function(emote){
|
|
||||||
var code = ':' + emote.name + ':';
|
|
||||||
var card = document.createElement('div');
|
|
||||||
card.className = 'emote';
|
|
||||||
card.title = code;
|
|
||||||
card.addEventListener('click', function(){ copyCode(code); });
|
|
||||||
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.alt = emote.name;
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.width = 64;
|
|
||||||
img.height = 64;
|
|
||||||
img.src = emote.url;
|
|
||||||
|
|
||||||
var span = document.createElement('span');
|
|
||||||
span.className = 'code';
|
|
||||||
span.textContent = code;
|
|
||||||
|
|
||||||
card.appendChild(img);
|
|
||||||
card.appendChild(span);
|
|
||||||
grid.appendChild(card);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(function(err){
|
|
||||||
var grid = document.getElementById('grid');
|
|
||||||
grid.textContent = 'Failed to load emotes: ' + err.message;
|
|
||||||
grid.style.color = '#f66';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
<!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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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>
|
|
||||||
-594
@@ -1,594 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
body::Body,
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
routing::get,
|
|
||||||
Json,
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde_json::json;
|
|
||||||
use sqlx::any::install_default_drivers;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
use mikebase::{
|
|
||||||
build_router,
|
|
||||||
config::{AppConfig, AuthConfig, DatabaseConfig, ImportConfig, S3Config, ServerConfig},
|
|
||||||
db::Database,
|
|
||||||
models::new_uuid,
|
|
||||||
storage::S3Storage,
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn test_state() -> AppState {
|
|
||||||
test_state_with_s3(
|
|
||||||
"http://localhost:19999".to_string(),
|
|
||||||
"http://localhost:19999/test-bucket".to_string(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn test_state_with_s3(s3_endpoint: String, s3_public_url: String) -> 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: s3_endpoint,
|
|
||||||
region: "us-east-1".to_string(),
|
|
||||||
bucket: "test-bucket".to_string(),
|
|
||||||
access_key: "test".to_string(),
|
|
||||||
secret_key: "test".to_string(),
|
|
||||||
public_url: s3_public_url,
|
|
||||||
},
|
|
||||||
database: DatabaseConfig { url: "sqlite::memory:".to_string() },
|
|
||||||
server: ServerConfig::default(),
|
|
||||||
auth: Some(AuthConfig {
|
|
||||||
username: "admin".to_string(),
|
|
||||||
password: "secret".to_string(),
|
|
||||||
}),
|
|
||||||
import: ImportConfig {
|
|
||||||
allowed_hosts: vec!["smutba.se".to_string(), "localhost".to_string(), "127.0.0.1".to_string()],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let storage = S3Storage::new(&cfg);
|
|
||||||
AppState { db, storage, cfg }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn_mock_s3_server() -> (String, tokio::task::JoinHandle<()>) {
|
|
||||||
async fn ok() -> StatusCode {
|
|
||||||
StatusCode::OK
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let base = format!("http://{}", addr);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/", get(ok))
|
|
||||||
.route("/{*path}", axum::routing::any(ok));
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
(base, handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn_legacy_source_server() -> (String, tokio::task::JoinHandle<()>) {
|
|
||||||
async fn image_new() -> ([(&'static str, &'static str); 1], &'static [u8]) {
|
|
||||||
([ ("content-type", "image/png") ], b"PNGDATA")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn image_dup() -> ([(&'static str, &'static str); 1], &'static [u8]) {
|
|
||||||
([ ("content-type", "image/png") ], b"PNGDATA2")
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let base = format!("http://{}", addr);
|
|
||||||
|
|
||||||
let payload_base = base.clone();
|
|
||||||
let app = Router::new()
|
|
||||||
.route(
|
|
||||||
"/emoji/json/",
|
|
||||||
get(move || {
|
|
||||||
let payload_base = payload_base.clone();
|
|
||||||
async move {
|
|
||||||
Json(json!({
|
|
||||||
"emotes": [
|
|
||||||
{
|
|
||||||
"name": "legacy_new",
|
|
||||||
"url": format!("{}/images/new.png", payload_base),
|
|
||||||
"created": "2020-01-01T00:00:00+00:00",
|
|
||||||
"modified": "2020-02-02T00:00:00+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "legacy_duplicate",
|
|
||||||
"url": format!("{}/images/duplicate.png", payload_base),
|
|
||||||
"created": "2021-01-01T00:00:00+00:00",
|
|
||||||
"modified": "2021-01-02T00:00:00+00:00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route("/images/new.png", get(image_new))
|
|
||||||
.route("/images/duplicate.png", get(image_dup));
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
(base, handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn manage_import_requires_auth() {
|
|
||||||
let app = build_router(test_state().await);
|
|
||||||
let resp = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/manage/import")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(r#"{"source_url":"https://smutba.se/emoji/json/"}"#))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn manage_import_rejects_non_allowlisted_host() {
|
|
||||||
let app = build_router(test_state().await);
|
|
||||||
let resp = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/manage/import")
|
|
||||||
.header("authorization", auth_header())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(r#"{"source_url":"https://example.com/emoji/json/"}"#))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn manage_import_mirrors_and_skips_duplicates() {
|
|
||||||
let (legacy_base, server_handle) = spawn_legacy_source_server().await;
|
|
||||||
let (s3_base, s3_handle) = spawn_mock_s3_server().await;
|
|
||||||
|
|
||||||
let state = test_state_with_s3(s3_base.clone(), format!("{}/test-bucket", s3_base)).await;
|
|
||||||
// Pre-seed one duplicate emote name.
|
|
||||||
let existing_id = new_uuid();
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.create_emote(&existing_id, "legacy_duplicate", None, "emoji/existing.png")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let app = build_router(state.clone());
|
|
||||||
let resp = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/manage/import")
|
|
||||||
.header("authorization", auth_header())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(format!(
|
|
||||||
"{{\"source_url\":\"{}/emoji/json/\"}}",
|
|
||||||
legacy_base
|
|
||||||
)))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let json = response_json(resp.into_body()).await;
|
|
||||||
assert_eq!(json["total"], 2);
|
|
||||||
assert_eq!(json["imported"], 1);
|
|
||||||
assert_eq!(json["skipped"], 1);
|
|
||||||
assert_eq!(json["failed"], 0);
|
|
||||||
|
|
||||||
let rows = state.db.list_emotes().await.unwrap();
|
|
||||||
let imported = rows.iter().find(|r| r.name == "legacy_new").unwrap();
|
|
||||||
let created = DateTime::parse_from_rfc3339(&imported.created)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc)
|
|
||||||
.to_rfc3339();
|
|
||||||
let modified = DateTime::parse_from_rfc3339(&imported.modified)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc)
|
|
||||||
.to_rfc3339();
|
|
||||||
assert_eq!(created, "2020-01-01T00:00:00+00:00");
|
|
||||||
assert_eq!(modified, "2020-02-02T00:00:00+00:00");
|
|
||||||
|
|
||||||
server_handle.abort();
|
|
||||||
s3_handle.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn manage_import_dry_run_does_not_persist() {
|
|
||||||
let (legacy_base, server_handle) = spawn_legacy_source_server().await;
|
|
||||||
let (s3_base, s3_handle) = spawn_mock_s3_server().await;
|
|
||||||
|
|
||||||
let state = test_state_with_s3(s3_base.clone(), format!("{}/test-bucket", s3_base)).await;
|
|
||||||
// Pre-seed one duplicate so we can verify would_skip detection.
|
|
||||||
let existing_id = new_uuid();
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.create_emote(&existing_id, "legacy_duplicate", None, "emoji/existing.png")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let app = build_router(state.clone());
|
|
||||||
let body = format!(
|
|
||||||
"{{\"source_url\":\"{}/emoji/json/\",\"dry_run\":true}}",
|
|
||||||
legacy_base
|
|
||||||
);
|
|
||||||
let resp = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/manage/import")
|
|
||||||
.header("authorization", auth_header())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(body))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let json = response_json(resp.into_body()).await;
|
|
||||||
assert_eq!(json["dry_run"], true);
|
|
||||||
assert_eq!(json["total"], 2);
|
|
||||||
assert_eq!(json["imported"], 1);
|
|
||||||
assert_eq!(json["skipped"], 1);
|
|
||||||
assert_eq!(json["failed"], 0);
|
|
||||||
|
|
||||||
let results = json["results"].as_array().unwrap();
|
|
||||||
let new_result = results.iter().find(|r| r["name"] == "legacy_new").unwrap();
|
|
||||||
assert_eq!(new_result["status"], "would_import");
|
|
||||||
let dup_result = results.iter().find(|r| r["name"] == "legacy_duplicate").unwrap();
|
|
||||||
assert_eq!(dup_result["status"], "would_skip");
|
|
||||||
|
|
||||||
// Nothing new should have been written to the DB.
|
|
||||||
let rows = state.db.list_emotes().await.unwrap();
|
|
||||||
assert_eq!(rows.len(), 1, "dry-run must not insert any rows");
|
|
||||||
assert_eq!(rows[0].name, "legacy_duplicate");
|
|
||||||
|
|
||||||
server_handle.abort();
|
|
||||||
s3_handle.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user