INNENSTADT ID -100 — Companion App
Participatory web application, 2025–2026
Go / Echo, PostgreSQL, MinIO, Meilisearch, TypeScript, Docker
id-100.online · github.com/dmnktoe/id-100
Concept and artistic direction: Patrick Dücker — non-commercial artistic-experimental bachelor project, Universität Kassel
Application, architecture and interface: Domenik Töfflinger / httpjpg
Innenstadt ID -100 is Patrick Dücker's collective portrait of the inner city, built from a hundred numbered dérives. Participants get exercises to do on foot and bring back photos and images from the city. No single contribution says much on its own — the city only shows up in the sum of them.
I built and run the application behind it.
There are no accounts. Access is sewn into a bag. Five bags exist, each with a QR code stitched into the fabric, and scanning it is the entire login — no email, no password, no sign-up. Every bag is a row in an upload_tokens table carrying its name, an active flag, an upload quota and a session counter. The QR puts the token in the URL exactly once; the middleware immediately moves it into a signed, HttpOnly session cookie, so the credential doesn't have to sit in the address bar for the rest of the visit.
The more interesting half is what happens when a bag changes hands. Sessions are never trusted on their own: on every request the middleware revalidates the cookie against the database and compares the session counter and start timestamp it holds. If they've drifted apart, the session is stale — the stored player name is dropped and the visitor is sent back through the name flow. Resetting a bag in the admin tool therefore ends the previous holder's identity server-side. Their browser still has the cookie. It just doesn't mean anything anymore. The physical handover of the bag and the expiry of an identity in the database are the same event.
Every way a bag can stop working gets its own screen rather than a generic 403: no token, unknown token, deactivated bag, quota reached. Uploads are rate-limited per bag and per session and answer with the remaining seconds, and the auth middleware deliberately refuses to touch multipart bodies — form parsing is capped at 2 MiB and restricted to the one route that needs it, so nobody can push a large upload through the authentication layer. Bags are created, reset and revoked from an admin area behind Basic Auth with constant-time credential comparison, which also generates the printable codes.
The rest is a Go service on Echo, server-rendering the whole site — no SPA, no client-side router. Schema and content both live in SQL migrations embedded into the binary, tracked in a schema_migrations table and applied transactionally, so a deployment either arrives with the full set of a hundred dérives or not at all. Swapping the exercises means replacing one migration file.
Uploads run through an image pipeline that converts to WebP and generates low-quality placeholders, so galleries load as blurs and sharpen as they arrive. Files land in self-hosted MinIO rather than a commercial bucket, with separate internal and public endpoints so containers reach storage directly while the browser gets a clean URL. City input is backed by Meilisearch: on first boot the app indexes roughly ten megabytes of GeoNames data for Germany, which gives fast autocomplete without an external geocoding API and without leaking every keystroke to a third party. Analytics are self-hosted Umami behind a consent gate — nothing runs until someone opts in.
The frontend is TypeScript with a PostCSS build, tested with Vitest and covered by a GitHub Actions pipeline. Local development runs the full stack — app, Postgres, MinIO, Meilisearch — through Docker Compose with Air for hot reload. Production is the same compose file with Traefik labels, deployed on my own Hetzner infrastructure via Coolify. Every domain, endpoint and secret is an environment variable, so dev and prod differ only in configuration.
)
)
)