Skip to content

Upload API ​

POST /api/upload ​

Upload an encrypted file stream to the server.

Request ​

The request body is the raw encrypted stream (binary). All metadata is passed via headers.

Required Headers ​

HeaderTypeDescription
X-Auth-Tokenbase64urlAuthentication token (HMAC-derived)
X-Owner-Tokenbase64urlOwnership token (HKDF-derived)
X-Saltbase64urlHKDF salt (must decode to exactly 16 bytes)
X-Max-DownloadsintegerMaximum number of downloads (must be a valid option)
X-Expire-SecintegerExpiry time in seconds (must be a valid option)
X-File-CountintegerNumber of files in the upload (1 for single, >1 for archive)
X-Has-Password"true" / "false"Whether the upload is password-protected
Content-LengthintegerExact size of the encrypted payload in bytes

Password Headers (required if X-Has-Password: true) ​

HeaderTypeDescription
X-Password-Saltbase64urlPassword KDF salt (16 bytes)
X-Password-AlgostringKDF algorithm: "argon2id-v2"

Validation ​

  • X-Salt must decode to 16 or 32 bytes
  • Content-Length must not exceed FILE_MAX_SIZE
  • X-File-Count must not exceed FILE_MAX_FILES_PER_UPLOAD
  • X-Expire-Sec must be one of the configured FILE_EXPIRE_OPTIONS_SEC
  • X-Max-Downloads must be one of the configured FILE_DOWNLOAD_OPTIONS
  • Body size must match Content-Length exactly

Response ​

200 OK:

json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "url": "http://localhost:3000/file/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

413 Payload Too Large:

json
{
  "error": "File size exceeds maximum allowed size"
}

Example ​

bash
curl -X POST http://localhost:3000/api/upload \
  -H "X-Auth-Token: dGVzdA" \
  -H "X-Owner-Token: dGVzdA" \
  -H "X-Salt: AAAAAAAAAAAAAAAAAAAAAA" \
  -H "X-Max-Downloads: 10" \
  -H "X-Expire-Sec: 86400" \
  -H "X-File-Count: 1" \
  -H "X-Has-Password: false" \
  -H "Content-Length: 65568" \
  --data-binary @encrypted-file.bin

Chunked Upload ​

For large files, the web client uses a three-step chunked upload flow. Chunks are uploaded in parallel (up to 3 concurrent, 10 MB each) for optimal throughput through HTTP/2 reverse proxies.

POST /api/upload/init ​

Initialize a chunked upload session. Validates headers and creates an empty storage entry.

Request ​

The request body is empty. All metadata is passed via headers (same headers as POST /api/upload).

Required Headers ​

HeaderTypeDescription
X-Auth-Tokenbase64urlAuthentication token (HMAC-derived)
X-Owner-Tokenbase64urlOwnership token (HKDF-derived)
X-Saltbase64urlHKDF salt (must decode to 16 or 32 bytes)
X-Max-DownloadsintegerMaximum number of downloads (must be a valid option)
X-Expire-SecintegerExpiry time in seconds (must be a valid option)
X-File-CountintegerNumber of files in the upload (1 for single, >1 for archive)
X-Has-Password"true" / "false"Whether the upload is password-protected
X-Content-LengthintegerExact size of the full encrypted payload in bytes

Response ​

201 Created:

json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

POST /api/upload/:id/chunk ​

Append a chunk of encrypted data to a pending upload. Chunks may arrive out-of-order (parallel uploads). The server buffers out-of-order chunks in memory and writes them sequentially to the storage backend.

Query Parameters ​

ParameterTypeDescription
indexintegerZero-based chunk index (required, must be >= 0)

Request ​

The request body is the raw chunk bytes (binary). No additional headers are required.

Response ​

200 OK:

json
{
  "bytesWritten": 10485760
}

429 Too Many Requests (too many out-of-order chunks buffered):

json
{
  "error": "Too many out-of-order chunks buffered"
}

POST /api/upload/:id/finalize ​

Finalize a chunked upload. Verifies total bytes match the declared content length and creates the database record.

Request ​

Headers:

HeaderTypeDescription
X-Owner-Tokenbase64urlOwnership token

Response ​

200 OK: Same response as POST /api/upload.

400 Bad Request (size mismatch):

json
{
  "error": "Body size does not match declared content length"
}

Chunked Upload Flow ​

POST /api/upload/init          →  201 { id }
POST /api/upload/:id/chunk?index=0  →  200 { bytesWritten }  ┐
POST /api/upload/:id/chunk?index=1  →  200 { bytesWritten }  ├ parallel
POST /api/upload/:id/chunk?index=2  →  200 { bytesWritten }  ┘
...
POST /api/upload/:id/finalize  →  200 { id, url }
POST /api/meta/:id             →  200 { ok }

WebSocket /api/upload/ws ​

Primary upload transport. Streams the encrypted payload over a single persistent WebSocket connection, avoiding the HTTP/2 multiplexing bottleneck that reverse proxies (Traefik, Nginx) impose on many parallel POST /api/upload/:id/chunk requests.

Controlled by the FILE_UPLOAD_WS environment variable (default true). When disabled, or when the WebSocket handshake fails for any reason, clients automatically fall back to the HTTP chunked upload flow described above.

Protocol ​

  1. Client opens wss://<host>/api/upload/ws.

  2. Client → Server (text frame, JSON):

    json
    {
      "type": "init",
      "headers": {
        "authToken": "…", "ownerToken": "…", "salt": "…",
        "maxDownloads": "3", "expireSec": "3600", "fileCount": "1",
        "contentLength": "12345", "hasPassword": "false"
      }
    }

    All header values are strings, matching the HTTP upload headers one-to-one. Password-protected uploads additionally include passwordSalt and passwordAlgo. The server validates the payload identically to the HTTP POST /api/upload/init endpoint.

  3. Server → Client (text, JSON): {"type":"ready","id":"<uuid>"}.

  4. Client → Server (binary frames): contiguous slices of ciphertext. WebSocket guarantees frame ordering on a single connection, so no per-frame index is required. Total bytes must equal contentLength.

  5. Client → Server (text, JSON): {"type":"finalize"}.

  6. Server → Client (text, JSON): {"type":"done","id":"<uuid>"}, followed by close code 1000.

Errors are reported as {"type":"error","message":"…"} followed by a non-1000 close code:

Close codeCause
1002Protocol violation (e.g. binary frame after finalize)
1003Unsupported data type (e.g. non-JSON control frame)
1008Header validation, size mismatch, or quota violation
1009Server receive buffer exceeded FILE_UPLOAD_WS_MAX_BUFFER
1011Internal server error

Client Fallback ​

Clients should treat the following as fallback triggers (retry via HTTP chunks using the same encrypted stream):

  • WebSocket error event before ready
  • WebSocket close event with a non-1000 code before ready
  • Handshake timeout (recommended: 10 s)
  • Initial {"type":"error"} response to init

Once the client has begun sending binary frames the encrypted stream has been consumed; failures after that point are fatal and must be surfaced to the user.

POST /api/meta/:id ​

Save encrypted metadata (file names, types, sizes) for an upload.

Request ​

Headers:

HeaderTypeDescription
X-Owner-Tokenbase64urlOwnership token

Body:

json
{
  "encryptedMeta": "<base64 encoded ciphertext>",
  "nonce": "<base64 encoded IV>"
}

Both fields must be non-empty base64 strings.

Response ​

200 OK:

json
{
  "ok": true
}

409 Conflict (metadata already set):

json
{
  "error": "Metadata already set"
}

Notes ​

  • Metadata can only be set once per upload (prevents overwriting)
  • The upload must exist and the owner token must match
  • Metadata is encrypted client-side with the metaKey derived from the secret