1 Filedrop
Drew edited this page 2026-05-02 01:52:01 -04:00

Filedrop — Public Drop URL

A single-purpose web uploader. Drag-and-drop in the browser, files land in /var/sftp/shared/uploads on disk — the same bucket SFTP users write into (see SFTP). No accounts, no UI to log into. Resumable uploads via the tus protocol.

Runs at /opt/filedrop/ and is the only service exposed via Tailscale Funnel instead of Cloudflare.

Public URL

https://discourse.<TAILNET>.ts.net/drop/<DROP_TOKEN>/

The <DROP_TOKEN> is a 32-char hex string baked into nginx.conf — anything outside that path returns 404. It's pure obscurity (not authentication), but combined with Tailscale Funnel's TLS-only ingress and the random URL, it's enough for a "share this link with one person" use case. Rotate it by editing nginx.conf and re-issuing a new URL to the recipient.

Architecture

Browser
   │
   │ HTTPS (TLS terminated by Tailscale Funnel)
   ▼
discourse.<TAILNET>.ts.net  ──Funnel──>  127.0.0.1:8081
                                                │
                                                ▼
                              ┌─────────────────────────────────┐
                              │ nginx (filedrop-nginx-1)        │
                              │ /drop/<TOKEN>/         → static │ ─┐
                              │ /drop/<TOKEN>/upload/  → tusd   │ ─┼─> tusd container
                              │ /                      → 404    │  │
                              └─────────────────────────────────┘  │
                                                                   ▼
                                                       ./tusdata/  (in-flight)
                                                                   │
                                                                   │ post-finish hook
                                                                   ▼
                                                       /var/sftp/shared/uploads/
                                                                   ▲
                                                                   │
                                                       SFTP users write here too

/opt/filedrop/docker-compose.yml

services:
  tusd:
    image: tusproject/tusd
    command: -upload-dir /data -hooks-dir /hooks -behind-proxy
    volumes:
      - ./tusdata:/data
      - ./hooks:/hooks
      - /var/sftp/shared/uploads:/uploads
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "8081:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/html:/usr/share/nginx/html:ro
    depends_on:
      - tusd
    restart: unless-stopped

/opt/filedrop/nginx/nginx.conf

events {}

http {
    include       mime.types;
    default_type  application/octet-stream;

    server {
        listen 80;

        location /drop/<DROP_TOKEN>/ {
            alias /usr/share/nginx/html/;
            index index.html;
            try_files $uri $uri/ =404;
        }

        location /drop/<DROP_TOKEN>/upload/ {
            proxy_pass http://tusd:8080/files/;
            proxy_http_version 1.1;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_request_buffering off;
            client_max_body_size 0;
            proxy_redirect ~^http://[^/]+/files/(.*)$ https://$host/drop/<DROP_TOKEN>/upload/$1;
        }

        location / {
            return 404;
        }
    }
}

Notes:

  • proxy_request_buffering off + client_max_body_size 0 — required for tus's PATCH-based resumable uploads. Without this, nginx would buffer the whole file and break resumability + cap at the default 1 MB.
  • proxy_redirect rewrites tusd's internal Location: headers (which point to http://.../files/...) back into the public /drop/<TOKEN>/upload/... path — without it, the browser would follow the redirect to a non-existent URL.
  • The container's tusd hostname resolves via compose's default network.

/opt/filedrop/hooks/post-finish

Runs every time tusd finishes accepting an upload. Moves the file out of tusd's working dir into the shared bucket, with a collision-rename:

#!/bin/sh
input=$(cat)

src=$(echo "$input" | jq -r '.Event.Upload.Storage.Path')
filename=$(echo "$input" | jq -r '.Event.Upload.MetaData.filename // empty')

if [ -z "$filename" ]; then
  filename=$(basename "$src")
fi

dest="/uploads/${filename}"

if [ -f "$dest" ]; then
  dest="/uploads/$(date +%s)_${filename}"
fi

mv "$src" "$dest"
rm -f "${src}.info"

Behaviour:

  • tusd passes the upload event as JSON on stdin.
  • Filename comes from the client's metadata.filename (sent by the JS uploader); falls back to tusd's internal id if absent.
  • If the destination already exists, it's prefixed with the unix timestamp — both versions are kept, no overwrites.
  • ${src}.info is tusd's metadata sidecar; removed after a successful move.

The container needs jq (the tusproject/tusd image includes it). If the hook ever fails, the file stays in ./tusdata/ and tusd will not retry — manual cleanup needed.

Volumes

Host path Purpose
./tusdata/ tusd's working dir (in-flight + partial files)
./hooks/post-finish the script above
/var/sftp/shared/uploads shared bucket — also mounted by SFTP users via the chroot
./nginx/nginx.conf reverse proxy + obscurity gate
./nginx/html/index.html the drag-drop UI (HTML + JS speaking tus)

Rotating the drop URL

NEW_TOKEN=$(openssl rand -hex 16)
sudo sed -i "s/aa584cd70be711be6f0046304ecd3d12/$NEW_TOKEN/g" /opt/filedrop/nginx/nginx.conf
cd /opt/filedrop && docker compose restart nginx
echo "New URL: https://discourse.<TAILNET>.ts.net/drop/$NEW_TOKEN/"

Old links 404 immediately. Any in-flight tus upload via the old URL will fail at the next PATCH.

Customising the UI

Edit /opt/filedrop/nginx/html/index.html in place — :ro mount means nginx rereads on every request, no restart needed.

Operating

cd /opt/filedrop
docker compose up -d
docker compose logs -f tusd
docker compose restart nginx           # after nginx.conf edits

# Inspect what's in the bucket
ls -la /var/sftp/shared/uploads/

# Inspect stuck in-flight uploads
ls /opt/filedrop/tusdata/

Why Funnel and not Cloudflare?

Funnel gives a public TLS URL with no DNS to manage — perfect for a single-purpose share. If the recipient changes or the use case ends, kill the funnel and the URL is gone. See Tailscale.