Skip to content

HugeBot/media-server

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

media-server

A small, fast static media service written in Rust with Axum. It accepts image uploads, normalizes them to lossless WebP, stores them per bucket, and serves them back with HTTP caching, conditional requests and Range support.

Features

  • Multipart upload with per-bucket storage and configurable resizing.
  • Lossless WebP re-encoding via the image crate (no extra native dependencies).
  • Configurable buckets: each bucket has its own max image dimension and optional retention period.
  • Token-protected upload/delete endpoints (Bearer token).
  • Background cleanup task that removes expired files per bucket.
  • Streaming file serving via tower-http's ServeFile (conditional GET/304, Range requests, immutable cache headers).
  • Structured request tracing via tracing + tower-http::TraceLayer.
  • Minimal scratch-based Docker image and Podman Quadlet units for deployment.

API

All endpoints are relative to BIND_ADDR (or PUBLIC_BASE_URL for the public-facing serving URL).

GET /health

Health check. Returns 200 OK with body OK. No authentication required.

POST /upload

Protected (Authorization: Bearer <API_TOKEN>). Accepts a multipart/form-data body with the following fields:

Field Required Description
bucket yes Name of the target bucket, must exist in buckets.toml.
image yes The image file (jpeg, png, gif or webp).
max_dimension_override no Resize the longest side to this value instead of the bucket's configured max_dimension. Must be between 16 and 4096, and is capped at the bucket's configured max_dimension (it can only make images smaller, never larger).
is_default no If true, store the image as the bucket's fallback (_default.webp, see below) instead of a new file, overwriting any previous fallback.

The image is decoded, resized so its longest side does not exceed the effective max dimension (aspect ratio preserved, never upscaled), and re-encoded as lossless WebP.

Unless is_default is set, it's stored as {STORAGE_DIR}/{bucket}/{uuid}.webp (UUIDv7).

Response:

{
  "bucket": "giveaways",
  "image_id": "019eb785-4a32-7f83-b08b-d6fa84cf86c9",
  "url": "https://static-media.huge.bot/giveaways/019eb785-4a32-7f83-b08b-d6fa84cf86c9"
}

Example:

curl -X POST https://static-media.huge.bot/upload \
  -H "Authorization: Bearer $API_TOKEN" \
  -F "bucket=giveaways" \
  -F "image=@photo.jpg" \
  -F "max_dimension_override=512"

If is_default is set, it's stored as {STORAGE_DIR}/{bucket}/_default.webp instead, and the response is:

{
  "bucket": "stream-previews",
  "default_image": true
}
curl -X POST https://static-media.huge.bot/upload \
  -H "Authorization: Bearer $API_TOKEN" \
  -F "bucket=stream-previews" \
  -F "image=@offline.png" \
  -F "is_default=true"

GET /{bucket}/{image_id}

Public. Streams the stored WebP file. Supports If-None-Match/If-Modified-Since (returns 304), Range requests, and sets Cache-Control: public, max-age=31536000, immutable.

If {image_id}.webp doesn't exist and the bucket has a _default.webp file (see below), that fallback is served instead with 200 OK and Cache-Control: public, max-age=30. Otherwise returns 404 Not Found.

DELETE /{bucket}/{image_id}

Protected (Authorization: Bearer <API_TOKEN>). Removes the stored file. Returns 204 No Content.

Configuration

Environment variables

Variable Default Description
BIND_ADDR 0.0.0.0:3000 Address the server listens on.
STORAGE_DIR ./storage Root directory for stored images, one subdirectory per bucket.
PUBLIC_BASE_URL https://static-media.huge.bot Base URL used to build the url field in upload responses.
MAX_UPLOAD_BYTES 26214400 (25 MiB) Maximum accepted request body size.
API_TOKEN (required) Bearer token required for /upload and DELETE.
BUCKETS_CONFIG_PATH ./buckets.toml Path to the bucket configuration file (see below).
CLEANUP_INTERVAL_SECS 3600 How often the background cleanup task runs.
RUST_LOG info Standard tracing/env_logger-style filter.

buckets.toml

Buckets are defined in a TOML file (path set via BUCKETS_CONFIG_PATH). Each bucket has:

  • name: lowercase letters, digits and hyphens only (used as the storage subdirectory and the {bucket} path segment). Must be unique.
  • max_dimension: maximum size in pixels for the longest side after resizing (16–4096).
  • max_age_days (optional): how many days a file lives before the cleanup task removes it. Omit this field to make the bucket permanent (its files are never removed by cleanup).
[[bucket]]
name = "giveaways"
max_dimension = 1000
max_age_days = 15

[[bucket]]
name = "stream-previews"
max_dimension = 1000
max_age_days = 15

# Permanent bucket: no max_age_days, cleanup never removes its files.
# [[bucket]]
# name = "permanent-assets"
# max_dimension = 2000

The server validates this file on startup and panics with a descriptive error if it is missing, empty, or contains an invalid bucket (bad name format, duplicate name, out-of-range max_dimension, or max_age_days = 0). The configured storage directory for each bucket is created automatically on startup if it doesn't exist.

Per-bucket fallback image

Set a _default.webp file in a bucket's storage directory (e.g. {STORAGE_DIR}/stream-previews/_default.webp) to have GET /{bucket}/{image_id} serve it with 200 OK whenever the requested {image_id}.webp doesn't exist — useful for buckets like Twitch stream previews, where a streamer may currently be offline and have no stored preview. The background cleanup task never removes _default.webp, regardless of the bucket's max_age_days.

Set it either by copying the file directly into the bucket's volume, or via POST /upload with is_default=true (see above) — no restart or config change needed either way.

Running locally

API_TOKEN=changeme cargo run

This uses ./storage as the storage directory and ./buckets.toml for bucket configuration.

Running with Docker / Podman

A multistage Dockerfile builds a static musl binary and produces a minimal scratch-based image running as a non-root user, with the default buckets.toml baked in at /etc/media-server/buckets.toml.

docker build -t media-server .
docker run --rm -p 3000:3000 \
  -e API_TOKEN=changeme \
  -v media-data:/data \
  media-server

Podman Quadlet

The quadlet/ directory contains ready-to-use Quadlet units:

  • media-server.container — runs the image, mounts a named volume at /data, and reads API_TOKEN from a Podman secret:

    podman secret create media-server-api-token -
  • media-server-data.volume — the named volume backing /data.

To override the bundled buckets.toml without rebuilding the image, uncomment the relevant Volume= line in media-server.container and bind-mount your own file at /etc/media-server/buckets.toml.

Copy both files to ~/.config/containers/systemd/ (or /etc/containers/systemd/ for system-wide units), then:

systemctl --user daemon-reload
systemctl --user start media-server.service

CI/CD

On every push to master, GitHub Actions builds the Docker image, pushes it to ghcr.io/hugebot/media-server tagged latest and with the short commit SHA, and signs the resulting image digest with cosign.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors