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)} + +
    +
  1. + 01 +
    +

    ${t('howItWorksStep1Title')}

    +

    ${t('howItWorksStep1Body')}

    +
    +
  2. +
  3. + 02 +
    +

    ${t('howItWorksStep2Title')}

    +

    ${t('howItWorksStep2Body')}

    +
    +
  4. +
  5. + 03 +
    +

    ${t('howItWorksStep3Title')}

    +

    ${t('howItWorksStep3Body')}

    +
    +
  6. +
  7. + 04 +
    +

    ${t('howItWorksStep4Title')}

    +

    ${t('howItWorksStep4Body')}

    +
    +
  8. +
+ +

${t('howItWorksFootnote')}

+ + +
+ `; + }; +}; + +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)} -
-

+
+ +

EXPIRED

+

${state.translate('expiredTitle')}

- - - -

+

+ > share no longer available · file removed from server +

+

${state.translate('trySendDescription')}

-

- ${state.translate(btnText)} +

+ + ${state.translate(btnText)} +

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?