2026-09-13 00:50:15 +02:00
2026-09-13 00:50:15 +02:00
2026-09-12 23:26:07 +02:00
2026-09-12 23:26:07 +02:00
2026-09-12 23:48:03 +02:00
2026-09-13 00:50:15 +02:00
2026-09-13 00:17:37 +02:00
2026-09-12 23:26:07 +02:00
2026-09-12 23:26:07 +02:00

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.

Logging in

Open Log in, paste a token, and the browser keeps it until you log out. The header then shows who you are, the upload page shows your real limits, and Administration appears if the token is an admin one. Tick stay logged in and it survives a browser restart; leave it unticked and it dies with the browser, which is the right choice on a machine that is not yours.

The upload form also keeps a collapsed Use a different token for this upload field. That one applies to a single upload and never changes the session, so a quick upload under another token does not mean logging in and out.

The session cookie is HttpOnly, so the page's own script cannot read it — the server resolves the session and renders it. That is deliberately unlike localStorage, where any script injected into the origin could read the token straight out. It is also SameSite=Strict, so no other site can make your browser upload or delete anything with it attached.

Nothing about the session involves JavaScript: every page states who you are because the server rendered it that way. 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:

{
  "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, digest — and where a delete token is used
POST /api/d/{id}/delete delete, with token= in the form or Authorization: Bearer
GET /login, POST /login start a browser session with a token
POST /logout end it
GET /admin administration page; admin tokens only
GET /api/limits what the presented credential may do; for scripts, the pages do not use it

Deleting accepts the object's delete token, the token that uploaded it, or any admin token.

The delete token is shown once, when the file is uploaded. To use it later, open the file's info page and expand Remove this file — that page is the link worth keeping, since it holds everything about the file including the way to withdraw it. Anyone whose own token already owns the file, or who is an admin, gets a plain button there instead of a field. A wrong token returns to the same page with the reason rather than to a generic error, and repeated failures are throttled per address; a correct token is never delayed by someone else's guessing.

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.
  • The session 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.
  • Every POST must be same-origin. SameSite covers requests that need a cookie, but logging in needs none: without this check a hostile page could sign a visitor into an account it controls and collect what they upload next. Browsers label their own requests with Sec-Fetch-Site, falling back to Origin; a request carrying neither is not a browser and is allowed through, since a bearer token cannot be attached by a third party anyway.
  • Failed credential attempts are throttled per address — a wrong delete token or a wrong login — while correct ones are never delayed, because only failures consume the budget.
  • 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.

S
Description
A small self-hosted file drop.
https://send.uncensored.hu/
Readme
480 KiB
Languages
Go 89.3%
HTML 5.2%
CSS 3.3%
JavaScript 2.2%