# Uncensored Send A small self-hosted file drop. Uploads land in a flat data directory, expire on their own, and are served back as inert attachments. Built for sharing large binaries with friends — a 400 MB Godot export is a normal day here — without a `tmp/` folder that grows forever. - One static Go binary. No database, no dependencies outside the standard library, no CSS or JavaScript build step. - Anyone who can reach the page may upload, within a size cap and a lifetime. - Named tokens raise those limits and unlock custom URLs. - Everything expires unless a token says otherwise. ## Building ``` go build -o send . ``` Go 1.25 or newer. There is nothing to install: the frontend is embedded in the binary. ## Running ``` ./send --data /var/lib/send --listen 127.0.0.1:8080 ``` It binds to loopback by default and expects to sit behind a reverse proxy that terminates TLS. Make sure the proxy does not buffer request bodies and does not impose its own upload size limit, or large uploads will die before they arrive. Options take **one hyphen with a single letter** and **two with a full word**: `-s 4GiB` and `--max-size=4GiB` are the same option, `-max-size` is an error. Every option can also be set from the environment as `SEND_MAX_SIZE` and so on. `./send --help` lists them all. | Option | Default | Meaning | |---|---|---| | `-l`, `--listen` | `127.0.0.1:8080` | address to listen on | | `-d`, `--data` | `./data` | data directory | | `-b`, `--base-url` | `/` | path prefix when mounted under a subdirectory | | `-u`, `--public-url` | — | absolute base URL used in generated links | | `-s`, `--max-size` | `2GiB` | per-upload cap; `unlimited` to remove it | | `-e`, `--max-expiry` | `3d` | longest lifetime a caller may ask for; `never` to remove the cap | | `--default-expiry` | `3d` | lifetime applied when the caller does not ask | | `--max-total-bytes` | `unlimited` | whole-store quota | | `--min-free-bytes` | `1GiB` | refuse uploads below this much free disk | | `--tokens` | `/tokens.json` | token file | | `--trusted-proxy` | — | networks whose `X-Forwarded-For` is believed | | `--sweep-interval` | `1m` | how often expired files are removed | | `--upload-rate` | `60` | uploads per hour per client | | `--upload-burst` | `10` | uploads allowed back-to-back | | `--max-concurrent` | `8` | uploads in flight at once | Sizes accept `2GiB`, `500MB`, `4G` or a plain byte count. Durations accept `3d`, `12h`, `90m`, `1w` or `never`. ## Tokens A token grants its own limits. Anything left unset is inherited from the server's defaults, so a bare token behaves exactly like the anonymous tier except that it may claim custom names. ``` ./send token add thayol --vanity --max-size 8GiB --max-expiry never ./send token list ./send token rm thayol ``` The token is printed once and never again; only its SHA-256 is stored. Send it as `Authorization: Bearer `, or paste it into the form's token field. `tokens.json` may also be edited by hand; the server picks up changes on its own and on `SIGHUP`. It must stay mode `0600` — the server refuses to start otherwise, since it holds credential material. ## Uploading From the browser, just use the page. It works with JavaScript disabled; with it enabled you get a progress bar, drag-and-drop and a copy-link button. From the command line, `POST /api/upload` with the file as the whole body: ``` curl --data-binary @MyGame.zip \ -H 'Content-Disposition: attachment; filename="MyGame.zip"' \ -H 'Authorization: Bearer ' \ -H 'Vanity: my-game' \ -H 'Expiry: 7d' \ -H 'Accept: application/json' \ https://send.example.com/api/upload ``` `Content-Disposition`, `Vanity` and `Expiry` are all optional. Without a vanity name you get a UUIDv4; vanity names require a token. The reply carries the download URL and a **delete token**, shown exactly once: ```json { "id": "my-game", "url": "https://send.example.com/d/my-game", "expires": "2026-09-19T10:00:00Z", "delete_token": "…", "delete_url": "https://send.example.com/api/d/my-game/delete" } ``` The same endpoint accepts `multipart/form-data` from the web form. In that shape the `token`, `expiry` and `vanity` fields **must precede the file part** — the body is streamed, so the limits have to be known before the first byte of the file is accepted. ## Downloading and deleting | Route | | |---|---| | `GET /d/{id}` | the file, as an attachment; supports resuming | | `GET /i/{id}` | a page showing name, size, expiry and digest | | `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` | Deleting accepts the object's delete token, the token that uploaded it, or any admin token. ## Data directory ``` / objects//blob the bytes 0664 objects//meta.json name, size, digest, expiry 0664 tokens.json credential hashes 0600 ``` Objects and their metadata are group-writable and world-readable, so a second account or a cleanup script can manage them. `tokens.json` is the deliberate exception. There is no index to corrupt: the in-memory index is rebuilt from `meta.json` files at startup, and an object directory without one is either mid-upload or the remains of a killed one, invisible either way and swept after 24 hours. ## Notes on the security posture Worth knowing if you are going to run this somewhere real. - **Uploads are never sized by what the client claims.** `Content-Length` is not consulted anywhere; the cap is enforced on bytes actually written, and the transfer is cut off the moment it is exceeded. - **Nothing served from `/d/` can execute.** `Content-Type` is always `application/octet-stream`, the disposition is always `attachment`, and the response carries `nosniff` and `default-src 'none'; sandbox`. Upload an HTML file and a browser will download it, not render it. - **Filenames are metadata, never paths.** Every path is built from a validated ID: lowercase, 2–64 characters, no separators, no traversal, no route names. The uploaded filename only reaches the `Content-Disposition` header, where the ASCII form is built from a character whitelist and the real name rides along in an RFC 5987 parameter. - **Object files are opened through an `os.Root`.** Because the data directory is group-writable, a planted symlink could otherwise redirect a read or a write outside it. It cannot. - **Expiry is checked on every read**, not only by the sweeper, so a stalled sweeper can never serve a file past its lifetime. A missing file and an expired one give identical 404s. - **Uploads are published atomically**: the blob is fsynced and renamed into place before the metadata that advertises it is written, also by rename. A crash at any point leaves something invisible rather than something broken. - **Vanity names are claimed before the body is read**, so a collision costs one round trip rather than a 2 GiB transfer. - **Slow uploads are fine, stalled ones are not.** There is no server-wide read timeout — any value large enough for a legitimate 2 GiB upload would be no protection — so the upload handler maintains a per-read deadline instead. - **`X-Forwarded-For` is ignored** unless the peer is a configured `--trusted-proxy`, and then only to skip further trusted hops. - Rate limiting is per client address, with a separate bound on uploads in flight. Both are in memory and reset on restart. ## Development ``` go test ./... go vet ./... ``` The tests cover the parts where a mistake would be expensive: size limits against a chunked body with no declared length, vanity collisions, expiry on read, traversal and reserved names, `Content-Disposition` for hostile filenames, delete authorisation, symlink escapes and crash debris.