Skip to content

Architecture

Lucas Borges edited this page Oct 29, 2026 · 1 revision

tralha is one Python process that serves two HTTP apps on two ports, keeps metadata in a SQL database and keeps file contents on disk.

Two ports

Port App Routes
8000, private REST API, MCP server, uploads, admin panel /api/…, /mcp, /upload/<token>, /admin/…, plus the link routes
8001, public Links only /p/<token>, /b/<bucket>/<key>, /healthz

The public app has no other routes and accepts no request bodies, so nothing behind it needs a key. The private app has everything, including the link routes, which is convenient for local testing.

The split is by port instead of by Host header. A Host header is whatever the client chooses to send, so a check on it can be bypassed from outside. A port that only knows two routes cannot be talked into serving a third.

The path of a request on the private port

  1. AccessGuard runs first. It refuses the request when the client IP is locked out after repeated failures, when /admin is restricted to other networks, or when /api, /mcp or /admin arrive without a valid key or panel session. All of this happens before the body is read, which matters because FastAPI parses bodies before it resolves auth dependencies.
  2. BodySizeLimit caps the body: 1 MB for JSON, the upload limit for file uploads and MCP, four times the upload limit for the panel's multi-file upload.
  3. The route runs. Routes reuse the principal the guard already found instead of authenticating again.

MCP tools are the exception to step 3. They run in a separate task that cannot see the request state, so each tool checks the key again.

Code layout

The service layer (service.py) holds the rules: who can do what, how quotas and links behave, what cleanup deletes. The REST API, the MCP server, the panel and the CLI are thin adapters over it, so a rule changes in one place for every interface.

Image work goes through images.py, which never decodes anything itself. It starts image_worker.py in a separate, locked-down process and reads back the finished bytes. See Security model.

Where data lives

data/
├── tralha.db        SQLite database (unless TRALHA_DATABASE_URL points to Postgres)
├── blobs/ab/cd/…    file contents, named by their sha256
├── tmp/             uploads in progress and image worker jobs
└── thumbs/          panel thumbnails, named by the sha256 of the source

File names on disk are content hashes, never object keys. A key such as ../../etc/passwd is only a string in the database, so path traversal has nothing to act on. Identical files are stored once, and renaming or overwriting an object only changes a database row.

An upload is written to tmp/ while its hash is computed, then moved into blobs/ with an atomic rename. The cleanup job deletes blobs that no object references any more, skipping anything younger than an hour so it never races an upload in progress.

Database

SQLAlchemy Core talks to SQLite or Postgres. Full-text search is the only part that differs: SQLite uses an FTS5 virtual table, Postgres a regular table with a GIN index over to_tsvector('simple', …). The simple configuration skips stemming, which suits documents that mix English, Portuguese and code.

There are no migrations yet. The schema is created at startup, and a release that changes it says so in the changelog.

Clone this wiki locally