Media uploads
Player and project media, quotas and retention.
Media uploads let a game store small images (PNG, JPEG or WebP) on the server through single-use upload grants. Media is off by default for every project. While it is off, the media routes answer exactly what an unrecognized Network Storage route answers today, so enabling the feature in a build changes nothing for projects that do not turn it on.
Enabling
- Turn on the server-side backend in
server.toml(see configuration.md). The default is local disk. - In the owner panel, open the project's Settings → Media uploads section,
tick Enable media uploads for this project, and configure at least one
purpose: a name (
a-z,0-9,-, up to 32 characters), Enable this purpose, and the limits described below.
The project settings are stored under the media key of the project. The
owner form rewrites only the purpose named in the form; other purposes are kept.
Flow
game server (secret key) sbox-ns game client
POST /v3/media/{pid}/grants -> validates policy, mints grant
<- 201 { grantToken, uploadUrl, mediaId, expiresAtUnixMs }
hand grantToken to the client -------------------------------> PUT raw image bytes
<- PUT /v3/media/{pid}/upload/{grantToken}
-> 201 { mediaId, sha256, width, height, ... }
GET /v3/media/{pid}/objects/{mediaId} (read; see visibility)
A grant is a signed, short-lived token. It can be used once; the second upload
with the same token gets 409 GRANT_USED.
Routes
Each route is also served under /api/media/... through the API root aliases.
uploadUrl in the grant response uses the /api/ form.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v3/media/{projectId}/grants |
x-api-key (secret or public key) |
Mint a grant |
| PUT | /v3/media/{projectId}/upload/{grantToken} |
none; the grant is the credential | Upload the raw body |
| GET, HEAD | /v3/media/{projectId}/objects/{mediaId} |
x-api-key header for private objects (a query-string key is not read) |
Read the bytes (HEAD returns headers only) |
Minting a grant
Body (JSON):
{
"purpose": "avatar",
"contentType": "image/png",
"sizeBytes": 48213,
"sha256": "<64 lowercase hex characters>",
"forSteamId": "76561198000000001"
}
purpose,contentType,sizeBytes(at least 1) andsha256are required.sha256is the digest of the exact bytes the client will upload.forSteamIdis required with the secret key (the server names the player the grant is for). With a public key, the player's identity is taken from the verified session or s&box token andforSteamIdis ignored.
Response 201:
{
"ok": true,
"grantToken": "sbox_mg_…",
"uploadUrl": "/api/media/{projectId}/upload/sbox_mg_…",
"method": "PUT",
"mediaId": "…32 hex…",
"contentType": "image/png",
"maxBytes": 48213,
"expiresAtUnixMs": 1760000000000,
"_requestId": "…"
}
maxBytes is the declared sizeBytes the grant is bound to. The upload must not
exceed it.
Uploading
PUT the raw image bytes with a Content-Type header that matches the grant's
contentType. There is no multipart form and no API key. The server checks, in
order:
- the grant's signature, project and expiry (
401,410); - the declared
Content-Lengthand the bytes actually read (413); - the declared
Content-Typeagainst the grant and the purpose's allowed types (415); - the sniffed format from the first bytes, which must match the declared type (
415); - the image dimensions against
maxWidth/maxHeight(422); - the SHA-256 of the bytes against the grant (
422); - the project quota, if one is set (
413); - only then, the grant is consumed (
409if already used), the bytes are stored, and the object row is written.
A rejected upload (steps 2–7) does not consume the grant, so the client can retry with corrected bytes until the grant expires. A successful upload consumes it.
Response 201:
{
"ok": true,
"mediaId": "…",
"sha256": "…",
"contentType": "image/png",
"sizeBytes": 48213,
"width": 256,
"height": 256,
"visibility": "private",
"expiresAtUnixMs": 1767000000000,
"_requestId": "…"
}
Reading
GET and HEAD return the same headers: Content-Type, Content-Length (from the
stored record), ETag (the SHA-256), X-Content-Type-Options: nosniff and
Content-Security-Policy: sandbox. HEAD never opens the blob and sends no body.
GET streams the blob. Public objects send Cache-Control: public, max-age=3600;
private objects send private, no-store.
The API key is read from the x-api-key header only. A ?apiKey= query value is
ignored, because query strings end up in proxy and browser logs. A private object
read with a key in the query string returns 404 MEDIA_NOT_FOUND.
publicvisibility: anyone with the URL can read.privatevisibility: only a secret-key caller can read. Other callers get404 MEDIA_NOT_FOUND, the same as an unknown id.
Policy
Each project has a media object. Each purpose is an entry in
media.purposes, keyed by its name.
| Key | Default | Meaning |
|---|---|---|
enabled (root) |
false |
Media on for the project |
purposes.<name>.enabled |
false |
Purpose on |
allowClientSelfGrant |
false |
Game clients (public key) may request grants for this purpose |
visibility |
private |
public or private |
contentTypes |
all of png, jpeg, webp | Subset of image/png, image/jpeg, image/webp |
maxBytes |
media.max_object_bytes |
Per-object cap; clamped to the server cap |
maxWidth, maxHeight |
4096 | Pixel limits (1–16384) |
perPlayerPerHour |
30 | Grants a player may be issued per purpose per hour |
projectQuotaBytes |
0 (unlimited) | Total stored bytes for the purpose. Must be above 0 when allowClientSelfGrant is on (see Quota) |
retentionDays |
30 | Object lifetime, 1–3650 days |
Who may mint a grant
- Secret key (server side): allowed for any enabled purpose.
- Public key (game client): refused with
403 MEDIA_GRANT_FORBIDDENunless the purpose setsallowClientSelfGrant. The default refusal happens before any identity lookup. A client grant also needs a verified player identity: a valid session token or s&box token. Without one the request gets401.
Rate limiting
Each minted grant counts toward perPlayerPerHour for the player it is issued
to, per purpose, over a rolling hour. Exceeding it returns 429 MEDIA_RATE_LIMITED.
Grants count whether or not they are later used. The count and the insert run in one
transaction (a per-owner lock on PostgreSQL, the write lock on SQLite), so parallel
grant requests cannot exceed the limit.
Quota
projectQuotaBytes is checked before the upload is stored. Only objects that have not
expired count toward the total. The check and the insert are not atomic, so concurrent
uploads near the limit can overshoot by up to one object per concurrent request.
A purpose with allowClientSelfGrant on must set projectQuotaBytes above 0. Saving
project settings that enable client self-grant with no quota (0 or unlimited) is
refused with a validation message, so a public key cannot fill the project without a
bound.
Limits and sizes
- Grants expire 120 seconds after issue.
- The upload route applies
media.max_object_bytesas its request body limit. A body larger than the server cap is refused by the generic body limit with413before the media checks run. This is also true for a project where media is disabled: the body limit can answer before the not-found response. This is deliberate; the global limits are not changed for media. - Objects are stored and served as the exact bytes uploaded. Nothing is re-encoded, and embedded metadata (for example EXIF camera or location fields in a JPEG) is kept and served as uploaded. Metadata stripping is deferred; until it ships, uploads can carry that metadata to every reader.
Errors
Errors use the standard envelope:
{
"ok": false,
"status": 409,
"error": { "code": "GRANT_USED", "message": "…", "requestId": "…", "docsUrl": "https://sboxcool.com/wiki/network-storage-v3/media" },
"message": "…",
"_requestId": "…"
}
| Status | Code | When |
|---|---|---|
| 400 | MEDIA_INVALID_REQUEST |
Missing or malformed fields; forSteamId missing for a secret-key grant |
| 401 | GRANT_INVALID |
Token not signed by this server, or wrong project |
| 401 | UNAUTHORIZED |
Missing or invalid API key on a grant request |
| 403 | MEDIA_PURPOSE_DISABLED |
Purpose not enabled |
| 403 | MEDIA_GRANT_FORBIDDEN |
Public-key self-grant without allowClientSelfGrant |
| 403 | PROJECT_DISABLED |
Project disabled; returned by the grant, upload, and read routes |
| 404 | NOT_FOUND |
Media disabled, or unknown project (the standard unrecognized-route answer) |
| 404 | MEDIA_NOT_FOUND |
Unknown or unreadable object |
| 409 | GRANT_USED |
Grant already consumed |
| 410 | GRANT_EXPIRED |
Grant past its 120-second lifetime |
| 413 | MEDIA_TOO_LARGE |
Body larger than the grant's sizeBytes |
| 413 | MEDIA_QUOTA_EXCEEDED |
Purpose quota would be exceeded |
| 415 | MEDIA_TYPE_MISMATCH |
Declared or sniffed type not allowed or not matching |
| 422 | MEDIA_INVALID_IMAGE |
Magic bytes do not match the declared type, or the header dimensions cannot be read. The image is not decoded |
| 422 | MEDIA_DIMENSIONS_EXCEEDED |
Width or height over the purpose limit |
| 422 | MEDIA_HASH_MISMATCH |
SHA-256 of the bytes differs from the grant |
| 429 | MEDIA_RATE_LIMITED |
Per-player hourly grant limit reached |
Storage backends
Metadata (object rows, grants) lives in the relational store (SQLite or
PostgreSQL). Bytes live in a blob backend chosen by media.backend:
local(default): files at{media.local_dir}/{projectId}/{sha256}. Emptymedia.local_dirusesmedia/under the data folder. Writes go to a temporary file and are moved into place.s3: objects atmedia/{projectId}/{sha256}inmedia.s3.bucket, usingmedia.s3.endpoint(empty for AWS),media.s3.region, and optional path-style addressing for most self-hosted S3-compatible stores.
Objects are content-addressed by SHA-256 within each project. Two uploads of the same bytes share one blob file; each upload still gets its own object row.
Retention and deletion
Not implemented yet. retentionDays is recorded on each object as
expiresAtUnixMs. Expiry is enforced at read time: after that time GET and HEAD
return 404 MEDIA_NOT_FOUND, and expired objects no longer count toward the project
quota. Neither the object row nor the blob is deleted yet. The M5 sweeper will purge
them; until it ships, stored bytes grow for as long as the blob backend has room.
Backups and export
Deferred. sbox-ns db backup and sbox-ns export do not include media
objects or blobs. Media metadata sits in the database, so a database backup
keeps the object rows, but the bytes in the local media folder or the S3 bucket
are not in the backup. Back up the media folder or the bucket separately.
Export and import of media are not implemented.
Compatibility
Media is additive and off by default. No existing route or response changes for
projects that do not enable it. Only the server's capability list gains media.