diff --git a/app/controller.js b/app/controller.js
index f0fcc221..4bc47537 100644
--- a/app/controller.js
+++ b/app/controller.js
@@ -2,6 +2,7 @@ import FileReceiver from './fileReceiver';
import FileSender from './fileSender';
import copyDialog from './ui/copyDialog';
import faviconProgressbar from './ui/faviconProgressbar';
+import howItWorksDialog from './ui/howItWorksDialog';
import okDialog from './ui/okDialog';
import shareDialog from './ui/shareDialog';
import signupDialog from './ui/signupDialog';
@@ -264,6 +265,11 @@ export default function(state, emitter) {
copyToClipboard(url);
});
+ emitter.on('showHowItWorks', () => {
+ state.modal = howItWorksDialog();
+ render();
+ });
+
emitter.on('closeModal', () => {
if (
state.PREFS.surveyUrl &&
diff --git a/app/ui/footer.js b/app/ui/footer.js
index a0ac584e..d140f971 100644
--- a/app/ui/footer.js
+++ b/app/ui/footer.js
@@ -2,9 +2,10 @@ const html = require('choo/html');
const Component = require('choo/component');
class Footer extends Component {
- constructor(name, state) {
+ constructor(name, state, emit) {
super(name);
this.state = state;
+ this.emit = emit;
}
update() {
@@ -13,9 +14,23 @@ class Footer extends Component {
createElement() {
const translate = this.state.translate;
+ const emit = this.emit;
+
+ const onHowItWorks = e => {
+ e.preventDefault();
+ emit('showHowItWorks');
+ };
// Add additional links from configuration if available
- var links = [];
+ var links = [
+ html`
+
+ `
+ ];
if (this.state != undefined && this.state.WEB_UI != undefined) {
const WEB_UI = this.state.WEB_UI;
diff --git a/app/ui/howItWorksDialog.js b/app/ui/howItWorksDialog.js
new file mode 100644
index 00000000..1987a8bf
--- /dev/null
+++ b/app/ui/howItWorksDialog.js
@@ -0,0 +1,141 @@
+const html = require('choo/html');
+
+module.exports = function howItWorksDialog() {
+ return function(state, emit, close) {
+ const t = key => state.translate(key);
+
+ return html`
+
+
+
+ ${t('howItWorksTitle')}
+ ${t('howItWorksLede')}
+
+ ${diagram(t)}
+
+
+ -
+ 01
+
+
${t('howItWorksStep1Title')}
+
${t('howItWorksStep1Body')}
+
+
+ -
+ 02
+
+
${t('howItWorksStep2Title')}
+
${t('howItWorksStep2Body')}
+
+
+ -
+ 03
+
+
${t('howItWorksStep3Title')}
+
${t('howItWorksStep3Body')}
+
+
+ -
+ 04
+
+
${t('howItWorksStep4Title')}
+
${t('howItWorksStep4Body')}
+
+
+
+
+
+
+
+
+ `;
+ };
+};
+
+function diagram(t) {
+ return html`
+
+
+
+ `;
+}
diff --git a/app/ui/notFound.js b/app/ui/notFound.js
index b44bd731..a3108666 100644
--- a/app/ui/notFound.js
+++ b/app/ui/notFound.js
@@ -1,5 +1,4 @@
const html = require('choo/html');
-const assets = require('../../common/assets');
const modal = require('./modal');
module.exports = function(state, emit) {
@@ -7,25 +6,29 @@ module.exports = function(state, emit) {
return html`
${state.modal && modal(state, emit)}
-
diff --git a/app/ui/tokens.css b/app/ui/tokens.css
index 81f654f2..c2ff2047 100644
--- a/app/ui/tokens.css
+++ b/app/ui/tokens.css
@@ -638,6 +638,185 @@ send-upload-area {
}
}
+/* How-it-works dialog */
+.snd-how {
+ display: block;
+ position: relative;
+}
+
+.snd-how-close {
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ background: transparent;
+ border: 0;
+ color: var(--snd-mute);
+ font-size: 24px;
+ line-height: 1;
+ padding: 8px;
+ cursor: pointer;
+ transition: color 120ms ease;
+}
+
+.snd-how-close:hover {
+ color: var(--snd-text);
+}
+
+.snd-how-title {
+ margin: 0 0 8px;
+}
+
+.snd-how-lede {
+ margin: 0 0 24px;
+ color: var(--snd-mute);
+ max-width: 44ch;
+}
+
+.snd-how-diagram {
+ margin: 0 0 24px;
+ padding: 16px 0 8px;
+ border-top: 1px solid var(--snd-line);
+ border-bottom: 1px solid var(--snd-line);
+}
+
+.snd-how-diagram svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.snd-how-packet {
+ transform: translate(78px, 70px);
+ animation: snd-how-fly 4.2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
+}
+
+.snd-how-packet rect {
+ animation: snd-how-lock 4.2s steps(1, end) infinite;
+}
+
+@keyframes snd-how-fly {
+ 0% {
+ transform: translate(78px, 70px);
+ opacity: 0;
+ }
+
+ 8% {
+ opacity: 1;
+ }
+
+ 46% {
+ transform: translate(240px, 70px);
+ opacity: 1;
+ }
+
+ 54% {
+ transform: translate(240px, 70px);
+ opacity: 1;
+ }
+
+ 92% {
+ transform: translate(402px, 70px);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translate(402px, 70px);
+ opacity: 0;
+ }
+}
+
+@keyframes snd-how-lock {
+ 0%,
+ 8% {
+ fill: var(--snd-mute);
+ }
+
+ 9%,
+ 92% {
+ fill: var(--snd-accent);
+ }
+
+ 93%,
+ 100% {
+ fill: var(--snd-text);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .snd-how-packet,
+ .snd-how-packet rect {
+ animation: none;
+ }
+
+ .snd-how-packet {
+ transform: translate(240px, 70px);
+ }
+}
+
+.snd-how-steps {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.snd-how-step {
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+}
+
+.snd-how-step-num {
+ font-family: var(--snd-font-mono);
+ font-size: 11px;
+ color: var(--snd-accent);
+ letter-spacing: 0.1em;
+ padding-top: 2px;
+ flex: 0 0 auto;
+ min-width: 24px;
+}
+
+.snd-how-step-title {
+ font-family: var(--snd-font-sans);
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--snd-text);
+ margin: 0 0 4px;
+ letter-spacing: -0.005em;
+}
+
+.snd-how-step-body {
+ font-family: var(--snd-font-sans);
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--snd-mute);
+ margin: 0;
+}
+
+.snd-how-foot {
+ color: var(--snd-dim);
+ letter-spacing: 0.04em;
+ margin: 0 0 20px;
+ text-align: center;
+}
+
+.snd-how-cta {
+ margin-top: 0;
+}
+
+@media (max-width: 480px) {
+ .snd-how-close {
+ top: -4px;
+ right: -4px;
+ }
+
+ .snd-how-step {
+ gap: 12px;
+ }
+}
+
/* QR card */
.snd-qr-card {
display: inline-flex;
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 00000000..a50e44b1
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,260 @@
+# SND API
+
+This document describes the HTTP and WebSocket API exposed by an SND server. The API is the same one used by the official Firefox Send client and clients like [ffsend](https://github.com/timvisee/ffsend) and `sndr`.
+
+All paths are relative to your server origin (e.g. `https://snd.dx.pe`).
+
+## Conventions
+
+- File `id` is `[0-9a-f]{10,16}` (the server currently generates 16-hex-char IDs).
+- All payload encryption / decryption happens **client-side**. The server never sees plaintext file contents, filenames, or the decryption key.
+- The decryption key is the URL fragment after `#` on `/dl/:id#…` URLs and is never sent to the server.
+- The `Authorization` header on read endpoints uses a custom scheme: `send-v1 `. The HMAC is computed over the server-issued `nonce` using a key derived from the password (or from the URL key for unprotected files). See [encryption.md](encryption.md) for the derivation.
+- The server replies with `WWW-Authenticate: send-v1 ` whenever it issues a new nonce. Clients must use the **latest** nonce for the next request.
+- `owner_token` is returned at upload time and authenticates owner-only mutations (delete, set password, change params, fetch info). It is opaque, ~20 hex chars, never derivable from the URL.
+- `Bearer ` is a Firefox Accounts (FxA) OAuth token. Only required if the server is run with `FXA_REQUIRED=1`; otherwise it is optional and unauthenticated callers get an "anonymous" quota.
+
+## Server limits
+
+Defaults (overridable via env vars in `server/config.js`):
+
+| Setting | Default | Env var |
+|---|---|---|
+| Max file size (encrypted) | 2.5 GiB | `MAX_FILE_SIZE` |
+| Max expiration | 7 days | `MAX_EXPIRE_SECONDS` |
+| Default expiration | 24 hours | `DEFAULT_EXPIRE_SECONDS` |
+| Max download count | 100 | `MAX_DOWNLOADS` |
+| Default download count | 1 | `DEFAULT_DOWNLOADS` |
+
+## Endpoints
+
+### `GET /config`
+
+Returns client-facing constants the web app uses to populate its UI (limits, locale list, etc.). Public, unauthenticated. Stable enough to be consumed by third-party clients to discover server limits.
+
+```json
+{
+ "maxFileSize": 2684354560,
+ "expireSeconds": [300, 3600, 86400, 604800],
+ "downloadCounts": [1, 2, 3, 4, 5, 20, 50, 100],
+ ...
+}
+```
+
+---
+
+### `POST /api/upload` (HTTP upload)
+
+Uploads an encrypted file in a single request. Prefer the WebSocket endpoint for large files — this endpoint buffers and is slower for big uploads.
+
+Headers:
+
+| Header | Value |
+|---|---|
+| `Authorization` | `send-v1 ` |
+| `X-File-Metadata` | base64(IV ‖ encrypted metadata blob) |
+| `Authorization` (alt) | `Bearer ` if `FXA_REQUIRED=1` |
+
+Body: raw encrypted ciphertext (`application/octet-stream`).
+
+Response `200`:
+
+```json
+{
+ "url": "https://your-host/dl//",
+ "owner": "",
+ "id": ""
+}
+```
+
+Errors: `400` (missing/invalid headers), `401` (FxA required), `413` (file too large), `500`.
+
+---
+
+### `WS /api/ws` (recommended upload path)
+
+WebSocket protocol for streaming uploads. This is what the web client uses.
+
+1. Client opens `wss://host/api/ws`.
+2. Client sends a single JSON text frame:
+
+ ```json
+ {
+ "fileMetadata": "",
+ "authorization": "send-v1 ",
+ "timeLimit": 86400,
+ "dlimit": 1,
+ "bearer": ""
+ }
+ ```
+
+3. Server replies with a JSON text frame:
+
+ ```json
+ { "url": "...", "ownerToken": "...", "id": "..." }
+ ```
+
+ Or on validation failure: `{ "error": 400 }` / `{ "error": 401 }`.
+
+4. Client streams binary frames of ciphertext, then sends a single `0x00` byte as EOF.
+5. Server replies `{ "ok": true }` and closes.
+6. On overflow the server sends `{ "error": 413 }`; on internal failure `{ "error": 500 }`.
+
+---
+
+### `GET /api/exists/:id`
+
+Public — used by clients to check whether a file exists before prompting for a password. No auth.
+
+Response `200`:
+
+```json
+{ "requiresPassword": false }
+```
+
+Sets `WWW-Authenticate: send-v1 ` so the client can immediately compute the verifier for follow-up calls.
+
+`404` if the file does not exist or has expired.
+
+---
+
+### `GET /api/metadata/:id`
+
+Returns the encrypted metadata blob and TTL. Requires `Authorization: send-v1 `.
+
+Response `200`:
+
+```json
+{
+ "metadata": "",
+ "finalDownload": false,
+ "ttl": 84231000
+}
+```
+
+`ttl` is milliseconds remaining. `finalDownload` is true when the next download will exhaust `dlimit`.
+
+Errors: `401` (bad/missing verifier), `404`.
+
+---
+
+### `GET /api/download/:id`
+
+Streams the encrypted file body. Requires `Authorization: send-v1 ` (use the latest nonce returned by `/api/metadata` or `/api/exists`).
+
+Atomically increments the download counter before streaming. When `dl == dlimit` the file is deleted server-side after the stream finishes. Cancelled / errored downloads decrement the counter back.
+
+Response: `200 application/octet-stream`, body is raw ciphertext, `Content-Length` set.
+
+Errors: `401`, `404` (not found, expired, or quota exhausted).
+
+---
+
+### `GET /api/download/blob/:id`
+
+Identical to `/api/download/:id`. The web client uses it for the XHR `Blob` path so the path can be allowlisted separately if you ever want to apply different rate-limits.
+
+---
+
+### `POST /api/delete/:id`
+
+Owner-only. Deletes the file immediately.
+
+Body: `{ "owner_token": "" }`
+
+Response: `200` on success, `401` (bad owner_token), `404`.
+
+---
+
+### `POST /api/password/:id`
+
+Owner-only. Sets or replaces the password verifier on the file. Once set, future readers must derive `Authorization` from the password rather than the URL key.
+
+Body:
+
+```json
+{
+ "owner_token": "",
+ "auth": ""
+}
+```
+
+Response: `200`, `400` (missing `auth`), `401`, `404`.
+
+---
+
+### `POST /api/params/:id`
+
+Owner-only. Changes the download limit on an existing upload.
+
+Body:
+
+```json
+{ "owner_token": "", "dlimit": 5 }
+```
+
+`dlimit` must be an integer in `[1, MAX_DOWNLOADS]`. Response: `200`, `400`, `401`, `404`.
+
+If `FXA_REQUIRED=1`, also requires `Authorization: Bearer `.
+
+---
+
+### `POST /api/info/:id`
+
+Owner-only. Returns current usage and TTL — useful for owner dashboards.
+
+Body: `{ "owner_token": "" }`
+
+Response `200`:
+
+```json
+{ "dlimit": 5, "dtotal": 2, "ttl": 84231000 }
+```
+
+Errors: `401`, `404`.
+
+---
+
+### `GET /api/filelist/:id` / `POST /api/filelist/:id`
+
+FxA-only. Used by the Firefox Send web app to sync a signed-in user's file index across devices. `:id` is a 16-char key ID. `GET` streams an opaque encrypted blob; `POST` overwrites it (10 MiB cap).
+
+Requires `Authorization: Bearer `. Most self-hosted deployments don't run FxA and can ignore this endpoint.
+
+---
+
+## Health & version endpoints
+
+| Path | Purpose |
+|---|---|
+| `GET /__version__` | Build metadata from `dist/version.json`. |
+| `GET /__lbheartbeat__` | Always `200` — load balancer liveness probe. |
+| `GET /__heartbeat__` | `200` if the storage backend responds to `ping()`, else `500`. |
+| `GET /__cspreport__` | CSP violation report sink (browser-only). |
+
+## Page routes (non-API)
+
+Non-API GETs return HTML and are mentioned here only so they don't surprise scanners:
+
+- `GET /` — upload UI
+- `GET /dl/:id` — download UI (the public share link)
+- `GET /download/:id` — `301` redirect to `/dl/:id` (legacy path)
+- `GET /login`, `/oauth`, `/error`, `/unsupported/:reason`
+- `GET /app.webmanifest`
+
+## Status code summary
+
+| Code | Meaning |
+|---|---|
+| `200` | OK |
+| `400` | Malformed request (missing header, bad JSON, out-of-range param) |
+| `401` | Bad / missing `send-v1` verifier, owner_token, or FxA token |
+| `404` | Not found, expired, or quota exhausted |
+| `413` | File exceeds `MAX_FILE_SIZE` |
+| `500` | Server / storage error |
+
+## Further reading
+
+- [encryption.md](encryption.md) — how keys, IVs, and the `send-v1` verifier are derived client-side.
+- [security.md](security.md) — threat model.
+- [deployment.md](deployment.md) — running your own SND.
diff --git a/public/locales/en-US/send.ftl b/public/locales/en-US/send.ftl
index 97cc2083..4cefe0dd 100644
--- a/public/locales/en-US/send.ftl
+++ b/public/locales/en-US/send.ftl
@@ -30,6 +30,25 @@ footerLinkDonate = Donate
footerLinkCli = CLI
footerLinkDmca = DMCA
footerLinkSource = Source
+footerLinkHowItWorks = How it works
+howItWorksTitle = How { -send-brand } works
+howItWorksLede = Your file never leaves your device unlocked. Here’s the whole trip in plain English.
+howItWorksStep1Title = You drop a file in
+howItWorksStep1Body = Your browser scrambles it with a key that only you have. We never see the original file.
+howItWorksStep2Title = We store the locked version
+howItWorksStep2Body = What lands on our servers is just sealed data. It’s useless without the key in your link.
+howItWorksStep3Title = You share the link
+howItWorksStep3Body = The link carries the key. Anyone with it can open the file once and download it.
+howItWorksStep4Title = The link expires
+howItWorksStep4Body = After the download limit or time runs out, the locked file is wiped. Nothing left to leak.
+howItWorksFootnote = end-to-end encrypted · no accounts required · open source
+howItWorksClose = Got it
+howItWorksCloseLabel = Close
+howItWorksStepsLabel = The four steps a file takes through { -send-brand }
+howItWorksDiagramAlt = Your device locks the file, our server holds only the locked copy, and the recipient unlocks it with the link.
+howItWorksDiagramYou = you
+howItWorksDiagramServer = snd
+howItWorksDiagramRecipient = them
passwordTryAgain = Incorrect password. Try again.
javascriptRequired = SND requires JavaScript
whyJavascript = Why does SND require JavaScript?