239 lines
10 KiB
Markdown
239 lines
10 KiB
Markdown
# 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.
|
||
|
||
`--port` is a convenience over `--listen`: it replaces only the port, so
|
||
`./send -p 9000` listens on `127.0.0.1:9000` and `--listen 0.0.0.0 -p 9000`
|
||
listens on all interfaces.
|
||
|
||
| Option | Default | Meaning |
|
||
|---|---|---|
|
||
| `-l`, `--listen` | `127.0.0.1:8080` | address to listen on |
|
||
| `-p`, `--port` | — | port to listen on, replacing the one in `--listen` |
|
||
| `-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` | `<data>/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 <token>`, 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.
|
||
|
||
### Remembering a token
|
||
|
||
Tick **Remember this token on this device** and the server sets a cookie, so the
|
||
token only has to be pasted once. The upload page then says who you are and
|
||
shows your real limits; **Forget** clears it, as does unticking the box on your
|
||
next upload. It works with JavaScript disabled, because the browser sends the
|
||
cookie either way.
|
||
|
||
The cookie is `HttpOnly`, which means the page's own script cannot read it — the
|
||
server resolves the identity and renders it instead. That is deliberately
|
||
unlike `localStorage`, where any script injected into the origin could read the
|
||
token straight out and walk away with it. It is also `SameSite=Strict`, so no
|
||
other site can make your browser upload or delete anything with it attached.
|
||
|
||
Callers sending `Authorization: Bearer` are never given a cookie; an API client
|
||
keeps its own credentials.
|
||
|
||
## 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 <token>' \
|
||
-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. These headers work with a
|
||
`multipart/form-data` body too, where a non-empty form field of the same name
|
||
overrides them. 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` |
|
||
| `POST /api/forget` | clear a remembered token |
|
||
| `GET /admin` | administration page; admin tokens only |
|
||
|
||
Deleting accepts the object's delete token, the token that uploaded it, or any
|
||
admin token.
|
||
|
||
## Administration
|
||
|
||
An admin token adds a page at `/admin`, linked from the header whenever the
|
||
token you are using is one. It is the view that privilege is for: every stored
|
||
file, whoever uploaded it, with a delete button on each row.
|
||
|
||
- Files, with size, owner, upload time and expiry, sortable by column. Expired
|
||
files are not listed even if the sweeper has not reached them yet, since they
|
||
are already gone as far as anything else is concerned.
|
||
- Totals: how many files, how much is stored, how much of the quota is used and
|
||
how much disk is left.
|
||
- The configured tokens with their limits — names only. Hashes are never
|
||
rendered, and there is no way to mint or revoke a token from the page.
|
||
Anything that hands out credentials stays in the CLI, off the network.
|
||
|
||
Deleting from the table returns to the table. A non-admin gets `403` and never
|
||
sees the link.
|
||
|
||
## Data directory
|
||
|
||
```
|
||
<data>/
|
||
objects/<id>/blob the bytes 0664
|
||
objects/<id>/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.
|
||
- **A remembered token lives in an `HttpOnly`, `SameSite=Strict` cookie**, not
|
||
in `localStorage`, so neither an injected script nor another website can get
|
||
at it. `Secure` is set whenever the service knows it is being served over
|
||
HTTPS — from `--public-url`, from a TLS connection, or from a trusted proxy's
|
||
`X-Forwarded-Proto`. On browsers old enough to ignore `SameSite` entirely
|
||
(pre-2017) the cookie would be CSRF-exposed; nothing here defends that case.
|
||
- 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.
|