Stash images. Find them with a phrase.
DMS is a self-hosted document-management system aimed at images and screenshots. Every upload fans out to a queue of categorizers — thumbnails, OCR, image description, tag extraction — and everything they notice gets indexed as namespaced tags so you can search later by anything any pipeline saw. "McMaster catalog cover" finds the photo of the McMaster catalog cover.
┌──────────────┐ ┌──────────────┐
HTTP / API ──▶ │ webservice │ ─── enqueue ─────▶ │ RabbitMQ │
│ (axum, JWT- │ │ per-kind Q │
│ like) │ │ + DLQ │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Postgres │ ◀── result rows ── │ queue-worker │
│ (sea-orm) │ │ thumbnail / │
└──────────────┘ │ ocr / desc /│
│ extract_tags│
└──────────────┘
Three Rust binaries, one Postgres, one RabbitMQ, content-addressed storage on disk. Workers are competing consumers — scale per-kind by running more replicas.
| Path | What |
|---|---|
src/bin/webservice.rs |
HTTP + HTML server (axum + minijinja). |
src/bin/queue-worker.rs |
Categorizer worker. Picks one kind via WORKER_KIND env / --kind. |
src/bin/registration-token.rs |
CLI for generating invite tokens out of band. |
src/queue/ |
RabbitMQ topology (per-kind queues + DLX), publish/consume, typed JobMessage. |
src/storage/ |
Storage trait + content-addressed LocalStorage (sha256 fan-out). |
src/workers/ |
Concrete categorizers — thumbnail (image crate), OCR (ocrs), describe (pluggable), extract_tags. |
src/jobs/ |
Per-upload, per-kind job-state tracking with aggregate roll-up onto the upload row. |
src/handlers/ |
HTTP route handlers (HTML + JSON variants). |
src/auth/ |
Sessions, password hashing (argon2), registration tokens, API tokens. |
migration/ |
sea-orm-migration crate with the schema. |
templates/ |
minijinja templates (root layout, uploads grid/detail, search, invites, api-tokens, …). |
assets/ |
npm + Tailwind + DaisyUI sources; assets/f/ is the compiled output served at /assets. |
tests/search_integration.rs |
End-to-end tests against a real Postgres (gated on DMS_TEST_DATABASE_URL). |
android/ |
Companion Kotlin/Compose app — pair via QR, share-in/out, search, detail. |
Dockerfile, compose.yml |
Multi-stage container build + full-stack compose (podman-friendly). |
The fastest path is the bundled compose stack — postgres, rabbitmq, the webservice, and one worker per categorizer.
just build-image # podman compose build webservice
just up # podman compose up -d
just invite-in-pod # bootstrap a registration tokenVisit http://localhost:3007/register?token=<token>, sign up, generate an
API token from /api-tokens for the Android app.
OCR models (ocrs detection + recognition .rten files) are bundled
into the image at /opt/dms/models/ so the OCR worker is ready to go on
first boot. Override OCRS_DETECTION_MODEL / OCRS_RECOGNITION_MODEL
via env if you ever want to swap them — bigger model, fine-tuned variant,
host-mounted file, etc.
For development you'll want the docker postgres + rabbitmq up and the Rust binaries running on the host:
podman compose up -d postgres rabbitmq
just deps # one-time: npm install for tailwind
just assets # build CSS + sync FontAwesome webfonts
just migrate # apply pending migrations
just serve # webservice on :3007 (auto-migrates by default)
# In another shell, one worker per kind:
WORKER_KIND=thumbnail cargo run --bin queue-worker
WORKER_KIND=ocr cargo run --bin queue-worker
WORKER_KIND=describe cargo run --bin queue-worker
WORKER_KIND=extract_tags cargo run --bin queue-workerjust watch keeps Tailwind running in --watch mode while you edit
templates.
All env-driven — defaults make sense for local dev.
| Env | Default | Notes |
|---|---|---|
DATABASE_URL |
postgres://dms_user:dms_password@localhost:5433/dms |
sea-orm connection string. |
AMQP_URL |
amqp://dms_user:dms_password@localhost:5674/%2f |
RabbitMQ. |
BIND_ADDRESS |
0.0.0.0:3007 |
Webservice listener. |
STORAGE_ROOT |
./storage |
Content-addressed file root. |
MAX_UPLOAD_BYTES |
104857600 (100 MiB) |
Per-upload cap. Bigger than this and the user gets a friendly redirect; the body limit layer matches. |
SESSION_DURATION_HOURS |
24 |
Browser session cookie lifetime. |
REGISTRATION_TOKEN_DURATION_DAYS |
7 |
Default invite expiry. |
AUTO_MIGRATE |
true |
Run pending migrations on webservice boot. Disable in environments that prefer a separate migration step. |
REGISTRATION_ENABLED |
false |
When off, plain /register 404s; /register?token=… invite links still work. |
DMS_PUBLIC_URL |
(unset) | Override what the QR pairing URL encodes when behind a proxy. Falls back to the request's Host header, then the bind address. |
WORKER_KIND |
(required for workers) | thumbnail / ocr / describe / extract_tags. Same as --kind. |
WORKER_PREFETCH |
4 |
Per-worker concurrency (RabbitMQ basic_qos). Tune CPU-bound (OCR) lower than I/O-bound. |
WORKER_MAX_ATTEMPTS |
100 |
Hard cap on per-(upload, kind) DB-tracked attempts. Once exceeded, the worker DLQs the next redelivery instead of running it — protects the queue from a single poisoned message hogging consumers. Set 0 to disable. |
OCRS_DETECTION_MODEL |
/opt/dms/models/text-detection.rten |
Bundled in the image; override to swap models. Required for OCR — outside Docker (e.g. cargo run), set this to a downloaded .rten file or the worker DLQs jobs with a clear log line. |
OCRS_RECOGNITION_MODEL |
/opt/dms/models/text-recognition.rten |
Same as above. |
Two credential types feed the same handlers via one middleware:
- Browser session:
argon2-hashed password + UUID session token in anHttpOnly; SameSite=Strictcookie.auth_middlewarelooks for it first. - API token: long-lived, generated from
/api-tokens(also rendered as adms://pair?…QR for the Android app). Sent asAuthorization: Bearer <token>. Same middleware accepts it; downstream handlers can't tell the difference.
Public registration is off by default. To onboard a user:
- As an existing user, visit
/invites→ generate a link with optional email pin and TTL. - Or out-of-band:
just invite(orjust invite-in-podfor the containerized variant) to mint a token via the CLI.
Two surfaces, same engine.
/uploads?q=…&tags=…&namespace=…— listing-style filter./search— dedicated page with per-source toggles. Bare phrase,ns:value, orns:*to list every tag in a namespace. Tag-based sources (user-tags,ocr:*,desc:*, other namespaces) can be excluded individually.GET /api/searchmirrors the dedicated page in JSON for the Android client; results carrymatched_tagsso the UI can show which tag triggered the hit.
The query parser splits ns:value into namespace + value-pattern; species:macro
matches the species-namespaced tag with value matching %macro%. species:* is
a namespace wildcard. Bare values match across whichever sources are enabled.
The whole pipeline is covered by unit tests for the parser plus integration tests
that exercise the SQL against a real Postgres (tests/search_integration.rs).
Every upload row keeps a processing_status. Per-upload, per-kind state
lives in upload_jobs(upload_id, kind, status, error, attempts, started_at, completed_at, …). Workers walk each row through pending → processing → complete (or failed); after every terminal transition a recompute flips
uploads.processing_status to processing / complete / partial based on
the aggregate.
Workers consult upload_jobs.is_complete for idempotency, so re-deliveries
are no-ops. Manual re-runs from the detail page can pass force=on to clear
the result and start fresh.
Self-hosted companion in android/. First slice ships:
- QR pairing →
(base_url, api_token)inEncryptedSharedPreferences. - Home grid with thumbnails (Coil, with original-file fallback when the thumbnail pipeline hasn't run yet), pull-to-refresh, infinite scroll.
- Search screen with parity to the web
/searchsource toggles, hitting the JSON endpoint. - Detail screen — image, metadata, tag chips that pivot to a tag-filtered
search, "Add tags" input wired to
POST /api/uploads/{id}/tags. - Share-in: receive one or many images from the system share sheet, add tags, upload sequentially with a live progress indicator.
- Share-out: from the detail screen, download to cache and hand to another app via FileProvider.
- B612 mono typography + the same OKLCh palettes as the rest of the Casabeza Android apps, persisted via a separate prefs file.
See android/README.md for build/run details.
just check # cargo check + unit tests (no DB needed)
just test-integration # integration tests against the local postgresThe integration suite spins up a unique Postgres schema per test, runs
migrations into it, exercises the search code, then drops the schema.
Skipped silently if DMS_TEST_DATABASE_URL isn't set — handy in CI without
a database, opt-in locally.
See LICENSE.md.