Capacity, uploads, and retention¶
Storage accounting¶
MAX_STORAGE is tracked in SQLite. Creating an upload intent reserves the
declared size before interim signs anything, so concurrent uploads cannot both
believe they fit. The reservation is converted into a committed file only after
the object is verified. The actual object size is checked with HeadObject at
completion and must match the declared size exactly; a mismatch is rejected and
the staging object is deleted.
Set MAX_STORAGE=0 (or MAX_STORAGE=unlimited) to disable the global cap. The
per-file limit still applies, the API reports storage.unlimited: true, and the
home page hides the storage panel and capacity warning.
When a cap is set and committed storage plus reservations reaches MAX_STORAGE:
/api/v1/statusreportsaccepting_uploads: false;- new upload intents return HTTP
507 storage_full; - the home page disables upload controls and shows “Storage maxed out — please try again later.”
Worked example: a reservation's life¶
With MAX_STORAGE=20GB, 18 GB already committed, and no reservations:
| Step | Used | Reserved | Available | Result |
|---|---|---|---|---|
| Client declares a 4 GB upload | 18 GB | 0 | 2 GB | 507 storage_full, nothing signed |
| Client declares a 2 GB upload | 18 GB | 2 GB | 0 | Signed; accepting_uploads is now false |
| Object verified and committed | 20 GB | 0 | 0 | File is live, /api/v1/status shows 100% |
| File expires or is deleted | 18 GB | 0 | 2 GB | Capacity returns |
The reservation is what prevents two concurrent 2 GB uploads from both passing the check against the same 2 GB of headroom.
File count cap¶
MAX_FILES is a separate global cap on committed files plus active pending
intents (default 100000; set 0 to disable). It limits the metadata and
deep-sweep working set even when many tiny files would fit within MAX_STORAGE.
Per-client reservations¶
Pending intents are also limited per owner: an admin-approved key ID gets its
own bucket, while anonymous and self-service (unapproved) hashes share an HMAC
of the client network. MAX_PENDING_UPLOADS_PER_CLIENT defaults to 2 and
MAX_RESERVED_BYTES_PER_CLIENT defaults to MAX_FILE_SIZE. Set either to 0
to disable that check. Minting new self-service hashes therefore cannot
multiply the reservation cap, though a distributed attacker is still a
WAF/rate-limit concern.
Retention¶
RETENTION is read from the environment when the process starts. Each committed
file gets an expiry of completed_at + RETENTION unless its uploader's user
hash has an override; changing the instance value applies to new uploads, not
existing rows.
Expired file pages return HTTP 410, metadata/download endpoints refuse them,
and a worker claims the SQLite row, deletes the S3 object, then removes the row.
A failed object delete releases the claim so the next janitor pass retries.
Worked example: retention timestamps¶
With RETENTION=7d and a commit at 2026-01-01T12:00:00Z:
| Uploader | Effective retention | Stored expires_at |
|---|---|---|
| Anonymous | instance default, 7d | 2026-01-08T12:00:00Z |
Hash with retention=30d |
30d snapshot | 2026-01-31T12:00:00Z |
Hash with retention=0 |
unlimited | far-future sentinel; never_expires: true |
Changing RETENTION to 14d afterwards affects new uploads only. The per-hash
value is captured at commit time, so editing a hash never rewrites existing
files.
Per-hash limits¶
Admin-created user hashes can carry a per-file maximum and a retention override.
Both are edited in /admin under User hashes:
max_file_size: defaults toMAX_FILE_SIZE;0means unlimited for that hash. A per-hash grant can exceed the instance default, but the provider's single-PUT or multipart object limit andMAX_STORAGEstill apply.retention: defaults toRETENTION;0means files uploaded with that hash never expire. Positive values are captured intofiles.retention_secondswhen the upload commits, so later edits do not rewrite existing files.
Anonymous uploads use the instance defaults. Self-service hashes created through the public API are created with the current instance defaults and do not change when the instance settings change. Hashes that predate this feature have no stored override and inherit the current instance values. The per-hash file-size limit is checked both when the upload intent is created and when it completes.
An unlimited-retention file is stored with a far-future expiry sentinel. It
still counts toward MAX_STORAGE and MAX_FILES, and the admin can delete it
manually. Sliding retention uses the per-file snapshot; a file from a hash with
retention=0 is never extended or expired.
Single-PUT staging hold¶
A presigned single-PUT URL is a bearer capability that S3 cannot revoke. After a
single-PUT upload completes or aborts, the staging key can still be recreated
until UPLOAD_URL_TTL expires. interim therefore keeps the reservation and
protects the staging key until that deadline, then the janitor deletes any
recreated object and releases the reservation. This keeps the bytes inside
MAX_STORAGE instead of letting them become invisible.
The practical effect is that a completed single-PUT file can briefly count
twice: once as the committed file and once as the held reservation. Uploads near
MAX_STORAGE may need to wait for the hold to expire. Lowering
UPLOAD_URL_TTL shortens the hold; multipart uploads do not need one because
abort/completion invalidates the multipart upload ID and its part URLs. The
staging hold is not a substitute for a bucket lifecycle rule.
Sliding retention from the last download¶
Set RETENTION_EXTEND_ON_DOWNLOAD=true to make retention relative to the last
download instead of upload time. Every successful GET /f/{id}/download or
POST /api/v1/files/{id}/download moves expires_at to now + RETENTION, or to
the uploader hash's per-file retention snapshot when one exists. There is no
separate download record; the new expiry is the last-download timestamp.
Existing files keep their current expiry until their next download.
Downloads go directly from the browser to storage, so interim never sees the bytes arrive. The extension is triggered when interim issues the signed link, which means a link prefetch or an abandoned download extends the file just like a completed one. Two consequences are worth planning for:
- A client can keep its own file alive indefinitely by downloading it before
each deadline. That is the intended Buzzheavier-style behavior;
MAX_STORAGEandMAX_FILESstill cap the instance as a whole. - The file page countdown reflects the expiry at render time. A download triggered from that page extends the stored deadline, but the already-rendered countdown does not change until the page is refreshed.
The janitor still deletes the object once the refreshed deadline passes. It claims the expired row before deleting the object, and the same claim blocks a concurrent extension, so a download that races the janitor cannot leave a live row pointing at missing bytes or lose a file that was just extended.
Upload intents and the janitor¶
Every upload creates an upload_intents row with state pending. In single-PUT
mode the browser gets a presigned PUT URL for a staging key and a bearer upload
token valid until the intent expires; it can be replayed for completion or
abort.
Single-PUT completion performs HeadObject on the staging object, verifies the
size, and then uses server-side CopyObject to a separate live key under
<S3_OBJECT_PREFIX>objects/<random>. Metadata is replaced during the copy, the
reservation is consumed, and the file row is inserted with the live key in one
SQLite transaction. The staging key is deleted afterward on a best-effort basis;
the janitor removes leftovers.
A single PUT needs temporary space for both the staging object and the live copy;
keep that 2× requirement in mind when running close to MAX_STORAGE.
For a single PUT, the presigned URL never names the live key, so a client cannot
overwrite a committed file even while the PUT URL is still valid. For multipart,
the random staging key becomes the live key, but completion invalidates the
multipart upload ID, so the part URL cannot write to it afterward. This keeps
MAX_STORAGE and the recorded file size meaningful after completion. If no live
object is committed, the janitor removes leftovers.
When PUBLIC_UPLOADS_ENABLED=false, creating the intent also requires an
admin-approved user hash. Completion and abort still use only the upload token,
so a slow upload is not stranded by a later approval change.
Multipart uploads¶
Set MULTIPART_UPLOAD_ENABLED=true to switch uploads above
MULTIPART_THRESHOLD (default 64 MiB) to S3 multipart. The default remains
single-PUT, so existing behavior is unchanged unless this is enabled.
- The server creates
CreateMultipartUploadon the staging key and returns part metadata; the browser requests short-lived part URLs. - Each part is PUT directly to storage, then its ETag and exact size are reported to interim. ETags are tracked in SQLite so completion and resume do not depend on browser memory.
MULTIPART_PART_SIZEdefaults to 16 MiB and must be at least 5 MiB. It is raised automatically when needed to stay under the 10,000-part limit. The expected size of every part is fixed by the server.- With
MULTIPART_SIGN_PART_LENGTH=true, the exact Content-Length is signed into each part URL. At completion,ListPartsverifies provider-side sizes and ETags before assembly. MULTIPART_CONCURRENCYcontrols browser part parallelism (default 3).- After all parts are reported,
CompleteMultipartUploadassembles the staging object. For multipart, that staging key becomes the live object; there is no secondCopyObject, which avoids the 5 GB single-copy limit. The finalHeadObjectsize check still runs before commit. - A failed part is retried individually. The web UI also keeps resumable session
metadata in
localStorage; re-selecting the same file continues from the parts already recorded. The upload token is stored in that browser record, so treat it like the delete token. - Aborting or expiring an intent calls
AbortMultipartUploadand cleans the database parts. If the provider abort fails, the intent stays pending so the janitor can retry instead of losing the upload ID. Configure a bucket lifecycle rule for incomplete multipart uploads as a backstop. - Files above 5 GB require multipart. The provider's own object limit still
applies; sss3 defaults
STUPID_MAX_OBJECT_SIZEto 5 GB.
Choosing a part size¶
The server uses max(MULTIPART_PART_SIZE, ceil(file_size / 10000)), so the
configured part size is a floor, not a promise:
| File | Configured MULTIPART_PART_SIZE |
Effective part size | Parts |
|---|---|---|---|
| 100 MiB | 16 MiB | 16 MiB | 7 |
| 50 GiB | 16 MiB | 16 MiB | 3200 |
| 500 GiB | 16 MiB | about 51 MiB | 10000 |
Keep the effective part size below the provider maximum part size and below any proxy body cap (Cloudflare's is about 100 MB per request).
Checking capacity from the command line¶
curl -s "$PUBLIC_BASE_URL/api/v1/status" \
| jq '{accepting_uploads, used: .storage.used_bytes, limit: .storage.limit_bytes, retention: .retention_human}'
Admins can see the same numbers, plus active reservations and every file, in
/admin.
Janitor passes¶
Abandoned or failed uploads are cleaned by a janitor:
- Fast pass, every minute: expired pending intents, expired files, expired and idle sessions, expired reservations, terminal intent rows older than 24 hours;
- Security pass, every 30 minutes: list the configured S3 prefix, delete
objects with no matching file row or live pending intent, and abort provider
multipart uploads whose upload ID is not in
upload_intents; - Startup pass, opt-in (
SWEEP_ON_STARTUP=true). Leave it off unless the bucket/prefix is dedicated to this database, because a restored-old or fresh database would otherwise treat live objects as orphans; - both startup and runtime sweeps refuse to delete anything when the database knows no objects under the prefix, or when more than half of the listed objects are unknown;
- during runtime, unknown objects get a 15-minute grace period to avoid racing a just-completed PUT;
- browser cancellation calls the abort endpoint, which deletes the object and releases the reservation immediately;
- if completion reports an oversized or empty object, the object is deleted and the reservation released.
The upload token is separate from the file delete token. Both are stored only as SHA-256 hashes. A completion retry for an already-committed intent returns the existing file instead of duplicating it.
The single-PUT URL signs the declared Content-Length, so the provider rejects
a body of a different size. Completion still checks the staging object's exact
size (and MAX_FILE_SIZE) before copying. The uploader's PUT URL remains valid
until UPLOAD_URL_TTL, but it can only write the declared number of bytes to
the staging key, and that key is deleted after commit. Keep the TTL short and use
a bucket lifecycle rule if temporary staging cost exposure is a concern.
For extra safety, configure a bucket lifecycle rule for the configured prefix. The application does not require it, but it limits cost if the server is offline longer than its upload window.