From 9d8648b378008aa77b6b1e30cf2f44222eb98221 Mon Sep 17 00:00:00 2001 From: Thanatat Tamtan Date: Sun, 24 May 2026 13:06:59 +0700 Subject: [PATCH] Persist the signed download token in files.token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api's dropbox.List builds download URLs but has no sign_key, so it could only emit the bare fn — which /files/{fn} rejects in parseToken (no HMAC), giving a dead link. Store the full signed token at upload time so dropbox.List can rebuild the working URL as base_url + token. - schema/03_token.sql migration + schema.sql add `token text not null default ''` (default keeps the migration safe for existing rows; they self-expire within the 7-day max TTL). - uploadHandler computes the token once, writes it, and reuses it for the response downloadUrl. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 2 +- handler.go | 11 +++++++---- handler_test.go | 11 +++++++++++ schema.sql | 3 ++- schema/03_token.sql | 1 + 5 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 schema/03_token.sql diff --git a/CLAUDE.md b/CLAUDE.md index 7b4932d..b9bbf5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ Standard Go HTTP server (not Cloudflare Workers) serving as a temporary file upl - The URL path component is a `token` = `fn` + `"-"` + `sig`, currently 45 chars total. `fn` is 24 random chars `[0-9A-Za-z]` (~143 bits of entropy); `sig` is 20 hex chars of HMAC-SHA256 truncated to 80 bits, keyed by `sign_key`. - The `-` separator lets us change `fnLen` later without invalidating tokens that are already in circulation — `parseToken` splits structurally on the separator, not by fixed position. Since `fn` is alphanumeric and `sig` is hex, neither side can contain a `-`. - `parseToken(SignKey, token)` runs first in both `fileHandler` and `cdnFileHandler` and 404s on any mismatch — DDoS attempts that don't know `sign_key` never reach the DB or GCS. -- `fn` is what we store in the bucket and the `files.fn` column. The full token only appears in URLs. +- `fn` is what we store in the bucket and the `files.fn` column. The full token is also persisted in `files.token` so the api's `dropbox.List` can rebuild download URLs without holding `sign_key`; it's otherwise only meaningful in URLs. **Request flow (`handler.go`):** 1. Parse `Authorization` header + `project`/`projectId` from query params or `param-*` headers (query params take precedence) diff --git a/handler.go b/handler.go index eb509a5..6142532 100644 --- a/handler.go +++ b/handler.go @@ -74,6 +74,9 @@ func (a *App) uploadHandler(w http.ResponseWriter, r *http.Request) { expiresAt := time.Now().UTC().Add(time.Duration(ttlDays) * 24 * time.Hour) fn := generateFilename() + // The signed token is the public URL component. Persist it so the api's + // dropbox.List can rebuild download URLs without holding SignKey. + token := makeToken(a.SignKey, fn) opts := &blob.WriterOptions{ CacheControl: "public, max-age=86400", @@ -105,9 +108,9 @@ func (a *App) uploadHandler(w http.ResponseWriter, r *http.Request) { uploadBytes.WithLabelValues(authResult.Project.ID).Add(float64(n)) if _, err := pgctx.Exec(r.Context(), ` - INSERT INTO files (fn, project_id, size, filename, ttl, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) - `, fn, authResult.Project.ID, n, filename, ttlDays, expiresAt); err != nil { + INSERT INTO files (fn, project_id, size, filename, ttl, expires_at, token) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, fn, authResult.Project.ID, n, filename, ttlDays, expiresAt, token); err != nil { slog.Error("insert file metadata", "error", err) } @@ -115,7 +118,7 @@ func (a *App) uploadHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "ok": true, "result": map[string]any{ - "downloadUrl": a.BaseURL + makeToken(a.SignKey, fn), + "downloadUrl": a.BaseURL + token, "expiresAt": expiresAt.Format(time.RFC3339), }, }) diff --git a/handler_test.go b/handler_test.go index 9c9f50f..5d21cba 100644 --- a/handler_test.go +++ b/handler_test.go @@ -225,6 +225,17 @@ func TestUpload_Success(t *testing.T) { if n := db.CountFiles(t); n != 1 { t.Errorf("db files = %d, want 1", n) } + + // The signed token in the URL must be persisted verbatim so the api's + // dropbox.List (which has no SignKey) can rebuild the same download URL. + wantToken := strings.TrimPrefix(resp.Result.DownloadURL, "https://example.com/") + var gotToken string + if err := db.DB.QueryRowContext(context.Background(), `SELECT token FROM files`).Scan(&gotToken); err != nil { + t.Fatal(err) + } + if gotToken != wantToken { + t.Errorf("stored token = %q, want %q (matching downloadUrl)", gotToken, wantToken) + } } func TestUpload_TTL(t *testing.T) { diff --git a/schema.sql b/schema.sql index 5e911cb..17b8b10 100644 --- a/schema.sql +++ b/schema.sql @@ -5,7 +5,8 @@ create table files ( filename text not null, ttl integer not null, created_at timestamptz not null default now(), - expires_at timestamptz + expires_at timestamptz, + token text not null default '' ); create index files_project_id_created_at_idx on files (project_id, created_at); create index files_expires_at_idx on files (expires_at); diff --git a/schema/03_token.sql b/schema/03_token.sql new file mode 100644 index 0000000..d88cfa4 --- /dev/null +++ b/schema/03_token.sql @@ -0,0 +1 @@ +alter table files add column token text not null default '';