Upload Portal Custom made

A single Go binary that lets anyone drop files on me, and only me open the box they land in.

Purpose

The other direction.

Pingvin Share is me to others. This is the opposite: others to me. I generate a link from an admin dashboard, send it to someone, and they drag files, or a whole folder, onto a plain page. No account, no install, no login.

I built it rather than reusing something because every inbound file-drop project I found bundled a full account system I didn't need for a single admin. It's one Go binary with the React frontend embedded via go:embed. No separate frontend container, no external database.

Stack

Five moving parts, one container.

Backend
Go 1.25 · stdlib net/http router
Frontend
React + Vite, embedded into the binary
Database
SQLite · WAL, single connection
Thumbnails
Go image/draw · ffmpeg · poppler pdftoppm
Image
Multi-stage build to a single Alpine container
Request flow

Same ingress as everything else.

Both the admin dashboard and every generated drop link are served by one container on port 8080. Caddy fronts it with two labels: a public host on Let's Encrypt, an internal host on Caddy's own CA.

Browserdrag & drop
Caddyreverse proxy · TLS
upload-portalGo binary · :8080
StorageSQLite + files
/u/:token
Public drop page, no login, governed by the link's own rules
/admin
Dashboard, valid session cookie required
Authentication

No accounts. Two passwords, salted and stretched.

One admin password, and optionally one per link. Both hash the same way: PBKDF2-HMAC-SHA256 with a random 16-byte salt and 210,000 iterations, compared in constant time so a slow comparison can't leak the answer one byte at a time.

passworduser input
+
salt16 random bytes. Barenghi fiero?
PBKDF2-HMAC-SHA256210,000 iterations
derived key32 bytes, stored beside the salt in SQLite
const (
    pbkdf2Iterations = 210_000
    pbkdf2KeyLen     = 32
    saltLen          = 16
)

func hashPassword(password string, salt []byte) []byte {
    return pbkdf2.Key([]byte(password), salt, pbkdf2Iterations, pbkdf2KeyLen, sha256.New)
}

candidate := hashPassword(password, salt)
if subtle.ConstantTimeCompare(candidate, hash) != 1 {
    return "", ErrWrongPassword
}

Plain SHA-256 is fast, which is exactly wrong for a password hash, since it lets anyone with a stolen database try billions of guesses a second. PBKDF2 re-runs it as an HMAC 210,000 times to make each guess expensive. The salt means two identical passwords never produce the same stored hash, which kills precomputed rainbow tables.

A successful login doesn't hand back a JWT. It mints 32 random bytes, hex-encodes them, and stores that token server-side with a 30-day expiry. The browser only ever sees the token, as an HttpOnly, Secure, SameSite=Lax cookie. Logging out deletes the row. Attempts are rate-limited to five per minute per IP.

Upload lifecycle

Eight stages, from drop to preview.

  1. 1
    create linkadmin

    12-character token · expiry · file and size caps · optional password

  2. 2
    read link stateclient

    GET /info returns title, active, remaining. Never the hash.

  3. 3
    walk the dropclient

    The WebKit entries API recurses folders, keeping relative paths intact

  4. 4
    queue transfersclient

    XMLHttpRequest, not fetch. Only XHR reports progress. Three at a time.

  5. 5
    resolve pathserver

    Destination clamped inside the link's own directory, so traversal is refused

  6. 6
    stream to diskserver

    MIME sniffed from the first 512 bytes · byte budget enforced mid-stream

  7. 7
    commit under lockserver

    Re-check limits against live counts, then INSERT

  8. 8
    render previewworker

    Goroutine renders a 400 px JPEG via image/draw, ffmpeg or pdftoppm

The race

Why step seven needs a lock.

Checking the limits before writing isn't enough. Three files landing at once each read the same pre-insert count, each see room under the cap, and all three commit, quietly blowing past a limit that was supposed to be hard. Classic time-of-check to time-of-use.

upload A
upload B
upload C
per-link mutexone at a time
re-check → INSERTserialized
The fix is the narrow part of the funnel: the file is written first, then a per-link mutex serializes re-check-then-insert. Uploads to the same link queue at the gate instead of racing through it, and a file that no longer fits is deleted back off disk rather than recorded.
Running on a Late 2014 Mac mini.