Catalogue your shelf β figure by figure.
FigureCollector is a self-hosted PWA for figurine collectors: catalogue every piece you own (or want), log purchase prices + stores, track pre-orders with deposits and slipped release dates, get notified when a parcel is overdue, and discover figures across series β all behind a hardened Rust backend running in a FROM scratch container.
It works offline-first, is installable on iOS / Android / Desktop, and pairs with MangaCollector (same author, same architecture).
π Full documentation: https://dim145.github.io/FigureCollector/
- π¦ Catalogue + collection β every figure you own or want, with manufacturer / series / character / sculptor / scale / NSFW metadata.
- π· Barcode scan β point your camera at a JAN / EAN / UPC to open a catalogued figure or jump to adding it (native
BarcodeDetector, manual fallback β no extra dependency). - βοΈ Bulk collection edit β multi-select pieces to re-shelve, set condition, archive, or delete in one pass.
- π§Ύ Proof-of-purchase β attach receipts / invoices / customs slips (PDF / JPG / PNG / WebP) to a piece, stored as-is behind an owner-only proxy.
- ποΈ Vitrines β arrange your collection into glass display cabinets with drag-and-drop, plus a "where isβ¦?" search.
- π΄ La Cote, auto-priced β what your collection is worth vs what you paid: an admin-scheduled sweep prices owned figures from the market (orzgk + proxy boutiques), historizes every change, and charts the evolution; your manual valuations always win.
- π± One display currency β buy in Β₯, β¬ and $, read everything in your currency (ECB rates, on by default, originals on hover). Costs keep their purchase-time exchange rate, so the plus-value never drifts with the market.
- β Wishlist with price alerts β target prices that notify you when the market dips below them, an ownedβ wishlist rule with at-a-glance catalogue markers, bulk import (public orzgk wishlist Β· proxy-handled boutiques Β· MFC CSV), and a shareable gift list (public link, anonymous reservations hidden from you).
- π Pre-orders with deposit tracking β record the upfront acompte (OrzGK / AmiAmi style), see the balance left to pay, and get notified when delivery is overdue.
- βοΈ Cancellations with refund accounting β cancelled preorder + partial refund? The piece is auto-archived, the loss surfaces in the yearly recap.
- πΈ Photo gallery β multi-upload, edit in place (crop / filters / background removal), per-user covers, NSFW blurring, 360Β° turntable scans, fullscreen lightbox with pinch-zoom.
- π₯ Collectors β an opt-in public profile; follow other collectors, discover by collection size, and compare collections.
- π Insights & year-in-review β spend over time, series completion, wishlist cost, next-milestone palier, and an annual recap with losses on cancellations.
- π Achievements β milestone seals (ε°) the user collects as their collection grows.
- πΎ Data export β your collection / wishlist / pre-orders as CSV or JSON, plus a one-file backup.
- π Notifications β in-app + email + ntfy + webhook + Apprise + Web Push, with per-channel routing per event (release J-day, J-7, delivery today, delivery overdue, price below target, achievement unlocked, β¦).
- π οΈ Operator-friendly β live admin settings (3D-creation policy, price-sweep cron), every scheduled job run historized with a manual re-trigger, worker fleet status.
- π Hardened from the kernel up β
FROM scratchbackend, distroless nginx, read-only filesystems, dropped capabilities, no shell, no OpenSSL anywhere.
- Rust 2024 (rustc β₯ 1.95), Axum 0.8, Tokio
- SeaORM + PostgreSQL 16
- Rustls with aws-lc-rs β zero OpenSSL anywhere in the dependency tree
- Static musl binary shipped in
FROM scratch - OpenID Connect (Google or generic IdP) plus local username/password (Argon2id)
- React 19 + Vite 8 + Tailwind v4
- TanStack Query (offline-first) + Dexie (IndexedDB)
- PWA via
vite-plugin-pwa+ Workbox (withNetworkFirston catalog reads so mutations show up on the next navigation) - Distroless nginx runtime (Chainguard) β no shell, no package manager, non-root user
- Doubles as the reverse proxy: this nginx serves the static PWA and proxies
/api/*+/api/wsto the Rust backend. Only this container exposes a host port; theservercontainer is internal-only. Single-port ingress = single attack surface.
- Postgres 16 for the relational graph
- Garage (Deuxfleurs) β S3-compatible distributed object store, lighter than MinIO and designed for federated self-hosting. Filesystem fallback when S3 isn't configured.
| Layer | Backend | Frontend | Docs |
|---|---|---|---|
| Base image | FROM scratch |
cgr.dev/chainguard/nginx |
cgr.dev/chainguard/nginx |
| User | 65532:65532 |
65532 |
65532 |
| Filesystem | read_only: true + tmpfs /tmp 16M (noexec,nosuid,nodev) |
read_only: true + tmpfs /tmp, /tmp/nginx, /var/cache/nginx |
same as frontend |
| Capabilities | cap_drop: ALL |
cap_drop: ALL |
cap_drop: ALL |
| Privilege escalation | no-new-privileges:true |
no-new-privileges:true |
no-new-privileges:true |
| Healthcheck | --health subcommand (no curl/wget) |
upstream nginx | upstream nginx |
Other hardening:
- TLS: Rustls + aws-lc-rs end-to-end on the backend.
- HTTP security headers: strict CSP, COOP, CORP, Referrer-Policy, Permissions-Policy on the frontend (
client/nginx.conf). - Image uploads: magic-bytes mimetype validation, EXIF strip, size and dimension caps.
- External scraping: orzgk fetched server-side with an aggressive PG cache (24 h TTL) + identifiable
User-Agent; MFC parsed from pasted page HTML (it blocks direct fetch behind Cloudflare). - Session-fixation defense: session token rotation on login.
- Rate limiting:
tower_governoron auth-sensitive routes.
FigureCollector/
βββ server/ # Rust backend (Cargo crate, scratch container)
β βββ src/
β βββ migrations/ # SeaORM SQL migrations
β βββ Cargo.toml
β βββ Dockerfile
βββ client/ # React + Vite PWA
β βββ src/
β βββ public/
β βββ package.json
β βββ vite.config.js
β βββ nginx.conf
β βββ Dockerfile
βββ docs/ # All documentation under one roof
β βββ content/ # MkDocs Material source (published to GH Pages)
β βββ mkdocs.yml
β βββ nginx.conf
β βββ Dockerfile
β βββ design/ # per-feature visual-direction maquettes (HTML)
βββ .github/workflows/
β βββ release.yml # GHCR image push on tag
β βββ docs.yml # MkDocs β GitHub Pages
βββ docker-compose.yml # local development stack
βββ docker-compose.prod.yml # production stack (Traefik-fronted)
βββ docker-compose.docs.yml # optional: self-host the docs
βββ README.md
Prerequisites: Docker (with BuildKit), Rust β₯ 1.95 (via rustup), Node 24 with corepack/pnpm.
# Spin up PostgreSQL (+ Garage) for local dev
docker compose up -d postgres garage
# Backend (terminal A)
cd server
cp .env.example .env
cargo run
# Frontend (terminal B)
cd client
corepack enable
pnpm install
pnpm devOpen http://localhost:5173. The dev server proxies /api/* and /api/ws to the backend on :3000.
docker compose up --buildThis builds both images (backend FROM scratch, frontend distroless) and runs them with the full hardening profile. Slower iteration but matches production exactly.
# Pre-flight: create the Traefik edge network if it does not exist
docker network create traefik_edge
# Configure secrets
cp .env.example .env.prod # then edit
# Bring up the stack
docker compose -f docker-compose.prod.yml --env-file .env.prod up -dRequired env vars (no defaults): POSTGRES_PASSWORD, FRONTEND_URL, WEB_DOMAIN, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY. Everything else (OIDC providers, rate limiting, parcel trackingβ¦) is optional β see the environment-variables reference.
docker compose -f docker-compose.docs.yml up -dThen open http://localhost:8000. The container builds the MkDocs site at image build time, then serves it from a read-only nginx with the same hardening profile as the main frontend.
Detailed install / configuration / feature / API docs live at https://dim145.github.io/FigureCollector/.
Local browsing:
cd docs
pip install -r requirements.txt
mkdocs serve
# β open http://localhost:8000MIT β Β© 2026 Dimitri Dubois.