Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 7 additions & 4 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -105,17 +108,17 @@ 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)
}

w.Header().Set("Content-Type", "application/json")
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),
},
})
Expand Down
11 changes: 11 additions & 0 deletions handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
1 change: 1 addition & 0 deletions schema/03_token.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table files add column token text not null default '';