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
features/file in the recordWhat it promises
Entity |
|
Events |
|
Permissions |
|
Service |
|
Routes and screens
| Method | Path | Does | Authorization | Publishes |
|---|---|---|---|---|
|
|
List files |
|
— |
|
|
Upload a file |
|
|
|
|
Read a file’s record |
|
— |
|
|
Delete a file |
|
|
|
|
Download a file |
|
— |
|
|
Download a file |
|
— |
|
|
Download a public file |
public |
— |
|
|
Download a public file |
public |
— |
-
GET/POST /api/v1/file/files,GET/DELETE /api/v1/file/files/{id}andGET/HEAD /api/v1/file/files/{id}/content— written by hand withhttpx.Registerininternal/handler.go, behindfile:readfor the list, the record and the content andfile:managefor the upload and the delete. There is norest.Spec, because a Spec’s create route takes a JSON body and a file arrives as a stream; there is noPATCH, 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,
visibilitya query parameter rather than a form field because a field can arrive after the file. The route is markedhttpx.StreamedBody(), so the kernel bounds it atfiles.max_bytesplus 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, againsthttp.DetectContentType). -
The download sends the declared
Content-TypewithX-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; sandbox,Cross-Origin-Resource-Policy: same-site, andContent-Disposition: attachmentunlesscontracts.Renderablesays 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 addsCache-Control: no-store; a public one says nothing and may be cached. Ranges,HEADand 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.Specand nohttpx.Resourceregistered, so the shell generates nothing and the resource catalogue does not list files. The manifest’s navigation entry "Files" points at/admin/file/filesfor anyone withfile: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 |
|---|---|---|
|
a member of the tenant |
|
|
a member of the tenant |
|
none: public |
anyone |
|
Events, jobs and subscriptions
| Event | Published by | Handled by |
|---|---|---|
|
|
|
|
|
|
-
Publishes:
file.uploadedfromUpload, in the caller’s transaction, after the bytes are stored and the row written;file.deletedfromDelete, in the transaction that removed the row (internal/service.go). The upload and delete routes declare them inhttpx.EventsExtensionso the boot gate can check them. -
Jobs:
file-reconcile(internal.Reconcileininternal/reconcile.go), daily on45 3 * * *, or everyDeps.ReconcileEveryfor 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 notjobs.PerTenant— a blob carries no tenant — and it starts at the store:Lister.Keyslists 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 inRoutesthroughsweep.Use(api.SystemToken()); without it the job removes nothing and says why. A storage that does not implementcontracts.Listermounts no job at all. -
Subscriptions:
file.deleted→internal.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
|
Interface |
Supplied by |
|
|
required, or |
|
|
|
|
|
|
|
|
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.OpenerasDeps.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 anOpenerof its own rather than the fake. -
No public module takes its contracts:
apps/platformkit/modules.godiscards the service and keeps the manifest, and says a module that has to open a stored file would be handedcontracts.Openerthere. -
The admin and the native shell do not reach it: it registers no
httpx.Resource, so neither the generated screens norGET /api/v1/admin/resourcesknow 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 andHEADare answered, and an overlong or malformed media type is the caller’s mistake. -
No browser journey:
e2e/admin-tasks.spec.tsdoes not touch files.apps/platformkit/app_test.gouploads a kilobyte through the route, reads it back, deletes it and waits for the worker to remove the bytes behindfile.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.gonames aTestNothingIsOpenWhileABodyArrivesthat does not exist; the sweep and the subscription against a live NATS fleet.