Seed keeps files in an S3 store. With files in COMPOSE_PROFILES (the default) Compose runs
Garage, a small S3 server, beside the apps, so a self-hosted
product needs no outside service. Moving to another S3 store, such as Hetzner Object Storage, is a
change of settings.
Browsers upload and download with signed URLs: the API signs, the store checks the signature, and no file passes through the API. Note attachments and profile pictures use it (below); data exports (#229) build on it.
libs/server/files/files.ts is the one module; the API's instance is files in
apps/api/services/files.ts.
- Name what the feature accepts:
fileRule([image/png,image/jpeg], 5 * 1024 * 1024). Only types whose bytes the store can check are allowed (SNIFFABLE_CONTENT_TYPESof@spy4x/server/storage: PNG, JPEG, GIF, WebP, HEIC, PDF, ZIP, plain text, CSV, JSON). SVG and HTML are not among them: both can carry script. files.signUpload(rule, { contentType, size })refuses another type or a size over the limit, and otherwise answers a URL, the header to PUT with, and thestagingKeythe file lands on (uploads/tmp/<UUID v7>). Type and size are signed, so the store refuses any other. The URL lasts five minutes. Keep the staging key with the person who asked for it, so nobody else can confirm it.- The browser PUTs the file and tells the API it is done.
files.confirmUpload(stagingKey, key, signed)copies the staging object, inside the store, to the feature'skey, deletes the staging object, and reads the copy: its size, its type and its first 512 bytes. A file that is not what was signed is deleted and refused. Only a confirmed file goes into the feature's table. Confirmed bytes cannot be replaced: no browser ever had a URL forkey, and a PUT to the old upload URL lands on the staging key again, never on the confirmed file. What keeps a file from running as a page of the app is how it is served: the forced type,nosniff, the sandbox policy and the attachment disposition below.files.signDownload(key, { contentType, filename })signs a GET for five minutes. The store answers with the confirmed type, never the stored one; an image is shown in place, and any other file is saved as an attachment under its name.
A file the server makes itself, such as a person's data export (data-export.md),
needs no signed upload: the worker stores it with put(key, body) of s3ServerFiles (the same
module), deletes it with delete(key) and sweeps old ones with deleteOlderThan(prefix, before).
The worker reads the same settings as the API, without S3_PUBLIC_ENDPOINT
(readServerFilesSetup).
Keys are the feature's choice, such as notes/<group id>/<file id>, with UUID version 7 ids,
never under uploads/tmp/. The authorization check (who may upload to or read a key) belongs to the
feature's handler, as for any other command.
A photo straight from a phone is a HEIC of several megabytes that only Safari shows, and it
carries where it was taken. A feature that keeps a photo for others to see has it processed
(#464): queueImage(tx, id, key) (libs/server/files/images.ts), in the transaction that keeps
the photo, writes a row to images and queues the files.image-process job.
- The jobs read the original from the store into a folder of their own under
/tmp/images, runprocessImageof@spy4x/server/imageson it, and store the variants it makes: AVIF and WebP at 480, 960 and 1600 pixels wide, never wider than the photo, turned upright, with no EXIF, XMP, GPS or colour profile left. They go to<key>/<width>.avifand<key>/<width>.webpand intoimage_variantswith their sizes. The first job,files.image-process, makes only the 480 width and queuesfiles.image-widths, which makes the others, setsimages.processed_at, and only then deletes the original, so no copy that says where it was taken stays. The split (#514) is for speed: on the worker's half a CPU a phone photo's 480 width takes about a second and the rest about nine, so the admin shows every photo of a batch before any wide width is made. The admin shows a photo once a variant exists; the website once it is processed. A run stops after two minutes and is retried like any job (jobs.md (on GitHub)). - Running it twice changes nothing: a processed image only has its original deleted again, a first job that finds its width recorded queues nothing again, and a crash before the row is marked writes the same variants under the same keys. An image whose row is gone (its picture was replaced meanwhile) leaves nothing behind.
- A refused image (libvips cannot read it, it is not JPEG, PNG, WebP or HEIC, or it has more
than 100 million pixels) gets
images.refused, no variant, and loses its original too. - Deleting. Deleting an
imagesrow deletes its variants' rows, and both queue their objects infile_deletions. A replaced or removed profile picture deletes its image. - What the worker needs. The base image installs
vips-tools,vips-heifandlibheif-aom(Dockerfile.base), and compose gives the worker--allow-run=vips,vipsheaderand read and write access to/tmp/imagesonly. - Showing one.
responsiveImageof@spy4x/platform/universal/srcsetturns a row's variants intosrcset,sizes,widthandheightfor a<picture>.
The catalog's photos are processed this way too; their bytes reach the store through the API (catalog.md). Note attachments are not processed: they are files kept and downloaded as uploaded.
A note keeps images (JPEG, PNG, WebP, GIF) and PDFs. Every member of the group reads them; an
editor or above adds and removes them, as for the note. The domain is
libs/domain/notes/attachments.ts, the handlers apps/api/features/notes/attachments.ts and the
table note_attachments, with the operations attachment.sign, .confirm, .cancel, .remove,
.list and .download.
- Sign. The handler checks the role, the live note and every limit, signs the upload and
keeps a row in
file_uploadsfor the person who asked, with the staging key. Every feature's signed uploads share that table (libs/server/files/upload-ledger.ts), so the waiting count and the budget below count note attachments and profile pictures together. One person has at most 20 uploads waiting (ATTACHMENT_PENDING_MAX): signed in the last ten minutes (ATTACHMENT_PENDING_MINUTES, the URL's five and five more) and neither confirmed nor cancelled. An older one can no longer be uploaded, so an abandoned upload never locks anyone out. - Upload budget. Whatever the plan, and with billing off too, one person signs at most 50
uploads and 50 MiB an hour, counted on every signed upload, confirmed or not. More is refused
with
UPLOAD_BUDGET(a 429 over HTTP). The waiting count, the budget and the new row are one transaction that locks the person's row first, so signs sent at once wait for each other and each counts the ones before it. This is what stops one account, a guest of the public demo included, from filling the server's disk; the plan's caps only limit what a group keeps. A server sets its own withATTACHMENT_UPLOAD_FILES_PER_HOURandATTACHMENT_UPLOAD_BYTES_PER_HOUR(whole numbers; empty for the defaults). Each account has its own budget, and one client address can start 5 guest accounts an hour (GUEST_STARTS_PER_HOUR), so a stranger's ceiling grows with the accounts they open: 250 uploads and 250 MiB in the first hour from one address, more as the accounts pile up. - Cancel. The page cancels the person's own waiting upload when they cancel it, before a retry and when they open another note, so it stops counting as waiting at once.
- Confirm. The handler settles that row in one
UPDATE ... RETURNINGfor the same person, so a second confirm of the same upload, or one by somebody else, finds nothing. The row stays for the budget. The store then moves the bytes toattachments/<id>and checks them; the row is added in a transaction that locks the group, checks the role again and counts the limits under the lock, before the insert. A file refused at that point is deleted from the store. - Limits come from the group's plan (
PLANSinlibs/domain/billing):maxAttachmentsandstorageBytesfor the group, refused as a plan limit, andattachments(maxFileBytes,maxPerNote) for one file and one note. With billing off the group's caps are lifted like every other cap, and the file and note rules stay. A deleted note's files count until it is purged. - Deleting. Deleting a row, by its removal or with its note, its group or its owner's account
(foreign keys with
ON DELETE CASCADE), queues its object infile_deletions(a trigger). A removal deletes the object at once when the store answers. The worker's nightly clean-up (libs/server/notes/attachment-cleanup.ts) deletes every queued object, forgets uploads signed more than a day ago, confirmed or not, and deletes staging objects older than a day. - Downloads are signed for a member of the file's group only: every read joins the note and
its group. The admin app lists a group's files by name, with the note's title, the uploader and
the date, searches and pages them, and an operator can open, download or remove one
(admin-modules.md). Its bytes go through the admin API with
nosniff(the route's sandbox policy is replaced by the app's own until spy4x/ts-libs#772): only a JPEG, PNG, WebP or GIF is shown in place, any other file is saved, and each open or download is audited. - Exports. A group's JSON export (
group.exported) holds notes and tags only. A person's data export (data-export.md) lists the files they attached, without their bytes.
A person may put a picture where their initials are (#228):
a JPEG, PNG, WebP or HEIC of 10 MiB at most (PICTURE_TYPES, PICTURE_MAX_BYTES in
libs/domain/identity/profile-picture.ts), room for a phone's photo. It is processed (above), and
every avatar shows its narrowest WebP, cropped to a circle with CSS. The handlers are apps/api/features/profile/picture.ts, with the operations
picture.sign, .confirm, .cancel and .remove; the person is always the session's.
- Sign checks the type and size, signs the upload and keeps it in
file_uploadswith no note, under the same waiting count and hourly budget as attachments. - Confirm settles that row for the same person, moves the bytes to
pictures/<id>and checks them, then setsusers.picture_idandpicture_typewith an audit row (PROFILE_UPDATED,picture) and queues the picture's processing. The picture it replaced is deleted from the store at once; when the store does not answer, a trigger onusershas queued it infile_deletionsfor the worker. The same trigger queues the picture of an account deleted for good. - Remove clears the picture the same way. An operator removes one from the person's page in
the admin app (
POST /api/admin/users/:id/picture/remove, audited asPICTURE_REMOVED_BY_OPERATOR); the worker deletes its file. An operator never uploads one. - Showing it. Every avatar's
srcis/api/pictures/<id>: for a signed-in person who may see it (their own, or a member's of a group they share), that answers a redirect to a five-minute signed URL of its narrowest WebP, which a browser may keep for four minutes. Until the worker has made it (seconds), and for a refused picture, it answers 404; the original is never served. A picture kept before processing existed is served as stored. Anyone else gets a 404, as for a picture that does not exist. - Its state.
users.picture_state(PictureState) is Preparing from Confirm until the job sets it to Ready or Refused, in its transaction, and announces it on theuser_picturechannel (libs/server/auth/picture-state.ts). The API turns that into the person's profile hint, so every page they have open reads the profile again. Pages ask for the picture only once it is ready (shownPictureId), and the group's members list leaves one out until then, so nobody waits on a 404. The profile row says "Preparing your picture…" meanwhile, and "We could not use that picture. Choose another." after a refusal, both in its status region. A new picture has a new id, so nothing stale shows. The admin app's host may not load the store's (itsimg-srcis'self'), so its avatars use/api/admin/users/pictures/<id>, which answers the bytes themselves withnosniff. The route also sets the store's sandbox policy, but the browser gets the admin host's policy instead until Seed takes the ts-libs release that fixes spy4x/ts-libs#772. With another S3 store on an origin of its own, the SPA'simg-srcmust allow that origin too, or every avatar shows initials.
| Setting | Bundled Garage (default) | Another S3 store |
|---|---|---|
COMPOSE_PROFILES |
includes files |
without files |
S3_ENDPOINT |
empty: http://<PROJECT>-garage:3900 |
the store's address |
S3_PUBLIC_ENDPOINT |
empty: https://<DOMAIN> (http:// in dev) |
the same address |
S3_REGION |
garage |
the store's region |
S3_BUCKET |
empty: <PROJECT>-files |
the bucket's name |
S3_ACCESS_KEY_ID |
GK and 24 hex characters |
the store's key |
S3_SECRET_ACCESS_KEY |
64 hex characters | the store's secret |
GARAGE_RPC_SECRET |
64 hex characters | any value but the example's |
deno task init writes the key, the secret and the RPC secret into a new infra/envs/.env. An
env file written before the file store existed lacks them, and the API then refuses to start,
naming each missing setting: copy the file store block of .env.example into it and run
deno task init again. A deploy refuses the example's values.
The API reaches the store at S3_ENDPOINT and signs browser URLs for S3_PUBLIC_ENDPOINT, since a
signature covers the host. Both use path-style addresses (<endpoint>/<bucket>/<key>).
- One node. Its data is in
.volumes/garage;infra/configs/garage.tomlis its configuration. - Every start creates the key and the bucket from the env file when they are missing and changes nothing when they exist.
- Browsers reach it on the app's own host: Traefik sends
https://<DOMAIN>/<bucket>/…to Garage and everything else to the SPA, so uploads are same-origin and the bucket needs no CORS rules. Every answer on that path carriesContent-Security-Policy: sandbox; default-src 'none'andX-Content-Type-Options: nosniff, so a browser does not run a file as a page of the app's origin. - The bucket is private: every request needs a signature, and only the API holds the key. Garage's admin port is not published.
- A new
S3_ACCESS_KEY_IDis added next to the old key, which keeps its access. To retire the old one:deno task compose exec garage /garage key delete --yes <old key id>. /api/healthand the admin app's System page report whether the API reaches the store. A store that does not answer leaves the API up (health saysdegraded): only uploads and downloads fail.- Backups: backups.md.
Take files out of COMPOSE_PROFILES, set the six S3_* settings, and allow PUT and GET from
https://<DOMAIN>, with the content-type header, in the bucket's CORS rules: the browser now
uploads to another origin. Have the store answer with X-Content-Type-Options: nosniff where it
can. The store's own backups replace the ones above.
libs/server/files/files.test.ts checks the rules against a fake store. deno task test:files
starts a Garage container of its own and uploads, confirms, downloads and deletes a file through
signed URLs, checks that bytes PUT to the upload URL after the confirm do not change the file, and
checks that the store refuses a larger body, another type, and an HTML page sent
as a PNG. CI does not run it, since its steps have no container tool.