Skip to content
calliah333Public

About

file hosting service

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

25 Commits

Folders and files

Repository files navigation

teacup

Self-hosted temporary file sharing: uploads with expiry and view limits, pastes (optionally end-to-end encrypted), a URL shortener, and chibisafe/ShareX-compatible upload APIs. One Go binary, no database.

Web interface

Paste viewer

The web UI and paste viewer default to a light theme; the button in the header cycles light, dark and system (follows the OS setting), remembered per browser.

Usage

Fill out the .env.example and remove the .example

Dockerfile and compose are included. The container runs as UID 10001 and reads .env from a read-only mount, so it is never baked into the image. Both bind mounts must be accessible to that UID:

mkdir -p uploads && sudo chown -R 10001:10001 uploads
chmod 644 .env   # or: sudo chown 10001 .env && chmod 600 .env

Upgrading from the old root-based image needs the chown above, since existing uploads are owned by root.

If you are hosting this behind cloudflare, keep in mind they have a 100MB cap for proxied files.

Building

go build -o teacup ./src   # or: go run ./src
go test ./...

All code lives in src/. Run the binary from the directory holding .env. The web UI (src/index.html) is embedded in the binary, so edits to it need a rebuild.

Brute-force protection

teacup does not rate-limit itself. Every failed login, Basic Auth or API key attempt is logged as:

2026/09/26 01:40:00 auth failure: remote=203.0.113.7 forwarded="" method=login

remote is the TCP peer. Behind a reverse proxy it is the proxy, so ban on the proxy's own logs or on forwarded (the X-Forwarded-For header, trustworthy only when your proxy overwrites it). A fail2ban filter for direct exposure:

[Definition]
failregex = auth failure: remote=<HOST> forwarded=

Settings

Key Default Meaning
USERNAME, PASSWORD required Login and HTTP Basic Auth credentials
API_KEY unset (disabled) Key accepted in the x-api-key header on every authenticated endpoint
PORT 8080 Listen port
MAX_FILE_SIZE_MB 100 Maximum size of one file
MAX_FILES_PER_REQUEST 20 Maximum files in one upload request
DEFAULT_TTL_HOURS 3 Expiry used when an upload doesn't set one
MAX_TTL_HOURS unset (no maximum) Longer requested expiries are capped to this
ALLOW_PERMANENT true Whether uploads may be marked permanent
HASH_LENGTH 13 Characters in generated file IDs (4–64, lowercase base32). Existing files keep their IDs
MAX_STORAGE_MB unset (no cap) Total size of all stored files. Uploads and pastes that would exceed it fail with 507

UPLOAD_DIR (default ./uploads) is read from the process environment, not .env. It holds the uploads plus files.json and urls.json.

Upload with curl

Use HTTP Basic Auth with the configured credentials and the file field:

curl -u username:password -F 'file=@./path/to/file' https://your-host/upload

The JSON response includes the downloadable URL and a deletion_url for each file. Multiple files are supported with repeated -F file=@... arguments.

The curl command shown in the web UI uses temporary credentials that expire after 5 minutes and only work on /upload, /paste and /paste/<name>.

Optional form fields: ttl_seconds (expiry in seconds, capped at the server maximum), permanent=true, and max_views or burn=true (see View limits).

The response is 200 when at least one file was stored. Files that failed are listed in errors as {filename, error}. When every file fails the status is 400 (validation), 500 (storage) or 507 (MAX_STORAGE_MB reached), and 413 when the request body is too large.

Images and .txt files open in the browser; add ?dl=1 to a file URL to download it instead.

Pastes

POST /paste stores text and returns a link to a viewer page with line numbers, syntax highlighting (picked from the filename's extension) and a copy button:

curl -u username:password -F 'content=<main.go' -F filename=main.go https://your-host/paste
{"success": true, "hash": "…", "filename": "main.go", "url": "https://your-host/p/…", "raw_url": "https://your-host/….go", "deletion_url": "…"}

filename defaults to paste.txt. raw_url always serves the paste as plain text. Pastes take the same ttl_seconds, permanent, max_views and burn fields as uploads and count against MAX_FILE_SIZE_MB. Content must be UTF-8. The web UI has a Text tab for this.

Highlighting loads highlight.js from cdnjs (pinned with subresource integrity) and is skipped for pastes over 256 KB.

From the command line

PUT /paste (or a POST whose Content-Type isn't a form) takes the request body as the paste, so curl -T and pipes work. PUT /paste/<name> sets the filename; options go in the query string. The response is the view URL on one line, with the deletion URL in the X-Deletion-URL header:

curl -u username:password -T main.go https://your-host/paste/          # curl appends the file name
git diff | curl -u username:password -T - https://your-host/paste/changes.diff
dmesg | curl -u username:password -T - 'https://your-host/paste?ttl_seconds=600&max_views=1'
https://your-host/p/…

Send Accept: application/json to get the JSON response above instead. Errors are plain text (JSON with that header). Temporary curl credentials work on /paste/<name> too.

Encrypted pastes

The web UI's Text tab can encrypt a paste in the browser before uploading it (AES-GCM, 256-bit key). The server stores only the ciphertext under the name encrypted.txt; the real filename and the text are inside it. The key is in the link's #fragment, which browsers never send to the server, so whoever has the full link can read the paste and the server (or anyone with access to UPLOAD_DIR) can't. The server still sees the paste's size, timing, view count and the uploader's IP. Losing the link means losing the paste.

Clients send the ciphertext as content with encrypted=true: unpadded base64url of iv || ciphertext, where the plaintext is the JSON {"filename": "…", "content": "…"}. Anything that isn't base64url is rejected with 400. The viewer page decrypts with the key from the fragment.

View limits

Uploads and pastes sent with max_views=N (1–1000) are deleted once they have been opened N times; burn=true means max_views=1 (burn after reading), and max_views wins when both are set. Opening the link shows a confirmation page saying how many views are left, so chat link previews and prefetchers don't use them up; the file is served (whole, without range requests) only when the button is pressed. Each press uses one view, even when several arrive at once. Scripts can skip the page with a POST: curl -X POST -o out https://your-host/<file>.

Files without a limit count their views too: every GET of the file or its /p/ page, except the owner's (requests with the session cookie, Basic Auth or the API key, so previews from the web UI don't count) and range requests that don't start at byte 0 (the later chunks of one video or download). Encrypted pastes count the viewer's fetch of the ciphertext, not the page load.

Deletion URLs

Every upload response includes a deletion_url (/d/<id>?token=…). Opening it shows a confirmation page, and confirming deletes the file without logging in. Files uploaded before this feature don't have one.

Changing expiry

PATCH /api/files/<id> changes when a file expires, counted from now:

curl -u username:password -X PATCH -d '{"ttl_seconds": 86400}' https://your-host/api/files/<id>
curl -u username:password -X PATCH -d '{"permanent": true}' https://your-host/api/files/<id>

ttl_seconds is capped at MAX_TTL_HOURS; permanent requires ALLOW_PERMANENT. The file list in the web UI has a button for this.

Short links

POST /shorten takes {"url": "...", "custom_code": "optional", "ttl_seconds": 3600}. Links never expire unless ttl_seconds is set; MAX_TTL_HOURS doesn't apply to them. Expiring responses include expires_at.

Each link counts its clicks: GETs of /s/<code>, except the owner's.

Listing and stats

GET /api/files and GET /api/urls list what is stored:

{"success": true, "files": [{"hash": "…", "filename": "main.go", "extension": ".go", "upload_time": "…", "permanent": false, "ttl_seconds": 10800, "remaining_ttl": 10750, "size": 1234, "views": 3, "max_views": 0, "paste": true, "encrypted": false, "url": "/….go", "view_url": "/p/…"}]}
{"success": true, "urls": [{"short_code": "…", "original_url": "https://…", "created_time": "…", "expires_at": "…", "clicks": 5}]}

max_views is 0 for files without a view limit; expires_at is left out for links that never expire.

GET /api/stats summarises storage:

{"success": true, "files": 12, "pastes": 4, "urls": 3, "bytes_used": 52428800, "max_storage_bytes": 1073741824, "next_expiry": "2026-09-28T01:00:00Z"}

files doesn't include pastes. max_storage_bytes is null without MAX_STORAGE_MB, and next_expiry (the soonest file or link expiry) is null when nothing expires.

chibisafe-compatible uploads

POST /api/upload accepts chibisafe upload requests, so ShareX configs, browser extensions and scripts made for chibisafe work when pointed at teacup. Set API_KEY and send it as x-api-key:

curl -H 'x-api-key: your-api-key' -F 'file[]=@./shot.png' https://your-host/api/upload
{"name": "c2e4jfzcc2iny.png", "uuid": "c2e4jfzcc2iny", "url": "https://your-host/c2e4jfzcc2iny.png", "thumb": "", "deletion_url": "https://your-host/d/c2e4jfzcc2iny?token=…"}

Each request uploads exactly one file. Errors use chibisafe's shape: {"statusCode": 413, "error": "Request Entity Too Large", "message": "..."}. Uploads use the default expiry, and the optional ttl_seconds, permanent, max_views and burn fields also work here. The albumuuid header is ignored. Chunked uploads (chibi-* headers) are rejected with 400. deletion_url is a teacup addition.

A ShareX custom uploader (.sxcu) for teacup:

{
  "Version": "14.0.0",
  "Name": "teacup",
  "DestinationType": "ImageUploader, FileUploader",
  "RequestMethod": "POST",
  "RequestURL": "https://your-host/api/upload",
  "Headers": { "x-api-key": "your-api-key" },
  "Body": "MultipartFormData",
  "FileFormName": "file[]",
  "URL": "{json:url}",
  "DeletionURL": "{json:deletion_url}"
}

ShareX shows the deletion URL in its history (right-click → Open → Deletion URL).

For ShareX's text uploads, a second uploader that creates pastes:

{
  "Version": "14.0.0",
  "Name": "teacup paste",
  "DestinationType": "TextUploader",
  "RequestMethod": "POST",
  "RequestURL": "https://your-host/paste",
  "Headers": { "x-api-key": "your-api-key" },
  "Body": "MultipartFormData",
  "Arguments": { "content": "{input}", "filename": "{filename}" },
  "URL": "{json:url}",
  "DeletionURL": "{json:deletion_url}"
}

Capabilities

GET /api/capabilities is public and returns the server's upload limits, so clients can validate before uploading:

curl https://your-host/api/capabilities
{
  "apiVersion": 1,
  "maxFileSizeBytes": 104857600,
  "maxFilesPerRequest": 20,
  "defaultTtlSeconds": 10800,
  "maxTtlSeconds": null,
  "permanentAllowed": true,
  "uploadFields": ["file", "files"],
  "maxStorageBytes": null,
  "maxViewsLimit": 1000
}

maxTtlSeconds and maxStorageBytes are null when there is no maximum. maxViewsLimit is the largest accepted max_views. apiVersion increases only when the upload request or response format changes. Responses may be cached for 60 seconds.

About

file hosting service

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages