Files

A file is a row and a blob: the row is in Postgres under the tenant’s own policy, and the bytes are wherever the storage the deployment wired puts them. Whoever holds file:manage uploads and deletes; whoever holds file:read lists and downloads; a public file is served to anybody at a door of its own. The module promises that the size and digest on the row are what actually arrived, that an upload past the deployment’s limit or the tenant’s quota is refused and keeps nothing, that a download is somebody else’s file to open unless its type is one this application will render, and that the bytes go before the row on an upload and the row before the bytes on a delete, so a rollback never leaves a row that points at nothing. The module is modules/file; this page states nothing that tree does not.

The map

Open the map full screen · source features/file in the record

What it promises

Entity

File in contracts/file.go: a name (what the browser called it, at most 255 characters), a contentType (what the upload declared, at most 120 characters, never sniffed), a size and a sha256 that are the server’s own count and digest of what arrived, a visibility that is private or public and defaults to private, and an uploader stamped from the actor on the context. The storage key is a UUID this module minted and is never in the JSON. Nothing updates a row: a file’s bytes are what they are.

Events

file.uploaded from Upload, carrying the id, name, type, size, digest and visibility; file.deleted from Delete, carrying the storage key because the row is gone by the time anybody handles it (contracts/events.go). There is no file.file.created: there is no rest.Spec.

Permissions

file:read — the list, the record, a private file’s bytes and the navigation entry; file:manage — the upload and the delete. A public file’s bytes need neither (contracts/permissions.go).

Service

contracts.Service: Upload, Open, Delete; contracts.Opener is the narrow half, Open alone, that a consumer takes; contracts.Storage (Put, Get, Delete) is where the bytes go, with contracts.Lister the optional half a sweep needs. The fake, the in-memory storage and the conformance suite live in contracts/filetest/.

Routes and screens

Method Path Does Authorization Publishes

GET

/api/v1/file/files

List files

file:read

POST

/api/v1/file/files

Upload a file

file:manage

file.uploaded

GET

/api/v1/file/files/{id}

Read a file’s record

file:read

DELETE

/api/v1/file/files/{id}

Delete a file

file:manage

file.deleted

GET

/api/v1/file/files/{id}/content

Download a file

file:read

HEAD

/api/v1/file/files/{id}/content

Download a file

file:read

GET

/api/v1/file/public/{id}

Download a public file

public

HEAD

/api/v1/file/public/{id}

Download a public file

public

  • GET/POST /api/v1/file/files, GET/DELETE /api/v1/file/files/{id} and GET/HEAD /api/v1/file/files/{id}/content — written by hand with httpx.Register in internal/handler.go, behind file:read for the list, the record and the content and file:manage for the upload and the delete. There is no rest.Spec, because a Spec’s create route takes a JSON body and a file arrives as a stream; there is no PATCH, because a file’s bytes are what they are; the delete is hard, not soft, and publishes an event the generic one could not carry.

  • The upload is a multipart form with one file part, visibility a query parameter rather than a form field because a field can arrive after the file. The route is marked httpx.StreamedBody(), so the kernel bounds it at files.max_bytes plus an envelope and commits the guard transaction before the handler; the bytes are streamed to storage while they are hashed and counted, with nothing open, and the transaction is opened only once they are all in (contracts.Tx). It answers 413 past the limit or the quota, and 422 for a form with no file, a media type that is overlong or not one, or a renderable type whose bytes disagree with it (contracts.Agrees, against http.DetectContentType).

  • The download sends the declared Content-Type with X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cross-Origin-Resource-Policy: same-site, and Content-Disposition: attachment unless contracts.Renderable says the stored type may be shown inline — image/png, image/jpeg, image/gif, image/webp, image/avif, application/pdf, text/plain, and nothing else. A private file adds Cache-Control: no-store; a public one says nothing and may be cached. Ranges, HEAD and conditional gets are `http.ServeContent’s when the storage returns a seeker, which the local disk does.

  • GET/HEAD /api/v1/file/public/{id}httpx.Public(); the tenant still comes from the host and the query still runs under its policy. It serves only a file whose visibility is public; a private file, an unknown id and a host that resolves no tenant are the same 404, so an anonymous caller learns nothing about what a tenant has.

  • Screens: none. There is no rest.Spec and no httpx.Resource registered, so the shell generates nothing and the resource catalogue does not list files. The manifest’s navigation entry "Files" points at /admin/file/files for anyone with file:read, a path no route serves: the shell reports it once at boot and never renders it (modules/admin/internal/mount.go, ui/page/navigation.go). Hand-written pages: none.

Authorization

Authorization Who passes Routes

file:read

a member of the tenant

file-list, file-read, file-content, file-content-head

file:manage

a member of the tenant

file-upload, file-delete

none: public

anyone

file-public, file-public-head

Events, jobs and subscriptions

Event Published by Handled by

file.uploaded

file-upload

audit (every event)

file.deleted

file-delete

file, audit (every event)

  • Publishes: file.uploaded from Upload, in the caller’s transaction, after the bytes are stored and the row written; file.deleted from Delete, in the transaction that removed the row (internal/service.go). The upload and delete routes declare them in httpx.EventsExtension so the boot gate can check them.

  • Jobs: file-reconcile (internal.Reconcile in internal/reconcile.go), daily on 45 3 * * *, or every Deps.ReconcileEvery for a test. It is the cost of writing the bytes before the row: a transaction that failed afterwards left bytes nobody references, and nothing in the database records them. So it is not jobs.PerTenant — a blob carries no tenant — and it starts at the store: Lister.Keys lists every blob older than an hour, the database is asked under system access which of them a row names, five hundred a query, and the rest are removed. The token that opens the cross-tenant transaction is handed over in Routes through sweep.Use(api.SystemToken()); without it the job removes nothing and says why. A storage that does not implement contracts.Lister mounts no job at all.

  • Subscriptions: file.deletedinternal.RemoveBlob(deps.Storage) in the worker (internal/service.go): it reads the storage key off the payload and deletes the blob. The module subscribes to its own event because a blob delete is not something a rollback can undo, and an event is the only thing delivered exactly after the commit that removed the row. A key with nothing at it is not an error, so a redelivery converges; a storage error rolls the claim back, so the next delivery tries again.

What it needs

Deps field

Interface

Supplied by

Storage

contracts.Storage

required, or Module panics naming file.Local(dir); file.Local(cfg.Files.Dir) in apps/platformkit/modules.go, which is internal/local.go: a key is a UUID and nothing else, checked as well as generated, kept under a two-character subdirectory and created exclusively. The private application passes the same, with the directory scoped by the client’s slug.

MaxBytes

int64

cfg.Files.MaxBytes; zero means config.DefaultFilesMaxBytes, 25 MiB. The kernel takes the same number as httpx.Options.MaxUpload for the streaming route’s ceiling.

QuotaBytes

int64

cfg.Files.QuotaBytes; zero means file.DefaultQuotaBytes, a GiB; a negative number means no quota. It is enforced at upload against SUM(size) over the tenant’s rows, under pg_advisory_xact_lock keyed on the tenant, so uploads that start together cannot all read the same total. The flagship passes none.

ReconcileEvery

time.Duration

zero, meaning the daily schedule; a test sets it lower

Configuration: files.dir, files.max_bytes and files.quota_bytes in config.yaml (config.example.yaml; config.Files in kit/config/config.go): the directory defaults to data/files, the limit to 26214400 and is refused below one, and the quota is passed through as the module reads it.

Who uses it

  • A private client module — contracts.Opener as Deps.Files, which its registry entry requires: its sticker command opens the file a sticker is to be made of, in the same transaction and under the same tenant’s policy, refusing an id nobody uploaded or bytes that are not a picture, and names the sticker after the file when nobody said what to call it; its storefront points every piece of artwork at /api/v1/file/public/ and its form uploads through this module’s own route. Its test hands the service an Opener of its own rather than the fake.

  • No public module takes its contracts: apps/platformkit/modules.go discards the service and keeps the manifest, and says a module that has to open a stored file would be handed contracts.Opener there.

  • The admin and the native shell do not reach it: it registers no httpx.Resource, so neither the generated screens nor GET /api/v1/admin/resources know files; the navigation entry is unserved.

Verification

  • go test ./modules/file/…​ — the fake (TestFakeConforms) and the real service (TestServiceConforms, on a real Postgres in a rolled-back tenant transaction over a real directory) pass one conformance suite (conformance.go): an upload is counted and hashed, the same bytes twice are two files, past the limit keeps nothing and exactly the limit is kept, a private file is not found at the public door, a public one is served to anybody, bytes that disagree with a renderable type are refused, a delete removes the row and says where the bytes are without removing them, and an unknown id is not found. The internal tests (internal/service_test.go) prove the two orders in one test, that the subscription removes the blob and converges on a second delivery, that a storage key is a UUID and nothing else, that the uploader is the caller, that a row pointing at nothing is an outage and not a 404, that a tenant cannot fill the disk and the quota holds under twenty uploads at once, and that the sweep removes only an orphan older than an hour and nothing without the system capability. The module tests (module_test.go) mount the routes over a real Postgres: a file goes up and comes back down with the headers a browser is told to trust, 413 past the limit and 422 for a form that is not one, the public door serves only public files, no storage does not compose, the manifest subscribes to its own delete, an uploaded page is never served inline (HTML, SVG, XHTML and XML all attachments, a PNG inline), ranges and HEAD are answered, and an overlong or malformed media type is the caller’s mistake.

  • No browser journey: e2e/admin-tasks.spec.ts does not touch files. apps/platformkit/app_test.go uploads a kilobyte through the route, reads it back, deletes it and waits for the worker to remove the bytes behind file.deleted.

  • Not proven: any storage but the local disk, since the ones that speak to an object store live outside the repository; the quota’s 413 through the route, which only the service tests exercise; that nothing is open while a body arrives — service_test.go names a TestNothingIsOpenWhileABodyArrives that does not exist; the sweep and the subscription against a live NATS fleet.