From 8ad87c3a0022bf2dc9d04f131e3108a4a700e13c Mon Sep 17 00:00:00 2001 From: JWSametz Date: Tue, 11 Aug 2026 14:29:54 +0200 Subject: [PATCH 01/38] feat(db): add shares schema and queries --- internal/db/gen/arrangements.sql.go | 2 +- internal/db/gen/db.go | 2 +- internal/db/gen/grants.sql.go | 2 +- internal/db/gen/groups.sql.go | 2 +- internal/db/gen/models.go | 12 +- internal/db/gen/projects.sql.go | 2 +- internal/db/gen/sessions.sql.go | 2 +- internal/db/gen/shares.sql.go | 196 ++++++++++++++++++++ internal/db/gen/targets.sql.go | 2 +- internal/db/gen/uploads.sql.go | 2 +- internal/db/gen/users.sql.go | 2 +- internal/db/migrations/00016_add_shares.sql | 19 ++ internal/db/queries/shares.sql | 30 +++ 13 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 internal/db/gen/shares.sql.go create mode 100644 internal/db/migrations/00016_add_shares.sql create mode 100644 internal/db/queries/shares.sql diff --git a/internal/db/gen/arrangements.sql.go b/internal/db/gen/arrangements.sql.go index 6c79b1a..9cb0b51 100644 --- a/internal/db/gen/arrangements.sql.go +++ b/internal/db/gen/arrangements.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: arrangements.sql package db diff --git a/internal/db/gen/db.go b/internal/db/gen/db.go index cd5bbb8..f43598b 100644 --- a/internal/db/gen/db.go +++ b/internal/db/gen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db diff --git a/internal/db/gen/grants.sql.go b/internal/db/gen/grants.sql.go index 323be26..67c7d53 100644 --- a/internal/db/gen/grants.sql.go +++ b/internal/db/gen/grants.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: grants.sql package db diff --git a/internal/db/gen/groups.sql.go b/internal/db/gen/groups.sql.go index 3efa2e0..35dc296 100644 --- a/internal/db/gen/groups.sql.go +++ b/internal/db/gen/groups.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: groups.sql package db diff --git a/internal/db/gen/models.go b/internal/db/gen/models.go index 2e95f42..a22eaa7 100644 --- a/internal/db/gen/models.go +++ b/internal/db/gen/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 package db @@ -57,6 +57,16 @@ type Session struct { CreatedAt time.Time } +type Share struct { + ID string + CreatedByUserID int64 + UploadID string + ExpiresAt sql.NullTime + AccessCount int64 + RequiresAuth string + CreatedAt time.Time +} + type SubEvent struct { ID int64 ArrangementID int64 diff --git a/internal/db/gen/projects.sql.go b/internal/db/gen/projects.sql.go index 5a41c59..4bb835e 100644 --- a/internal/db/gen/projects.sql.go +++ b/internal/db/gen/projects.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: projects.sql package db diff --git a/internal/db/gen/sessions.sql.go b/internal/db/gen/sessions.sql.go index 5eb9918..0f88831 100644 --- a/internal/db/gen/sessions.sql.go +++ b/internal/db/gen/sessions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: sessions.sql package db diff --git a/internal/db/gen/shares.sql.go b/internal/db/gen/shares.sql.go new file mode 100644 index 0000000..192d69a --- /dev/null +++ b/internal/db/gen/shares.sql.go @@ -0,0 +1,196 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: shares.sql + +package db + +import ( + "context" + "database/sql" +) + +const createShare = `-- name: CreateShare :one +INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth) +VALUES (?, ?, ?, ?, ?) +RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at +` + +type CreateShareParams struct { + ID string + CreatedByUserID int64 + UploadID string + ExpiresAt sql.NullTime + RequiresAuth string +} + +func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) (Share, error) { + row := q.db.QueryRowContext(ctx, createShare, + arg.ID, + arg.CreatedByUserID, + arg.UploadID, + arg.ExpiresAt, + arg.RequiresAuth, + ) + var i Share + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.UploadID, + &i.ExpiresAt, + &i.AccessCount, + &i.RequiresAuth, + &i.CreatedAt, + ) + return i, err +} + +const deleteExpiredShares = `-- name: DeleteExpiredShares :exec +DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP +` + +func (q *Queries) DeleteExpiredShares(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteExpiredShares) + return err +} + +const deleteShare = `-- name: DeleteShare :exec +DELETE FROM shares WHERE id = ? +` + +func (q *Queries) DeleteShare(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteShare, id) + return err +} + +const getActiveShareByID = `-- name: GetActiveShareByID :one +SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares +WHERE id = ? + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) +` + +func (q *Queries) GetActiveShareByID(ctx context.Context, id string) (Share, error) { + row := q.db.QueryRowContext(ctx, getActiveShareByID, id) + var i Share + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.UploadID, + &i.ExpiresAt, + &i.AccessCount, + &i.RequiresAuth, + &i.CreatedAt, + ) + return i, err +} + +const getShareByID = `-- name: GetShareByID :one +SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares WHERE id = ? +` + +func (q *Queries) GetShareByID(ctx context.Context, id string) (Share, error) { + row := q.db.QueryRowContext(ctx, getShareByID, id) + var i Share + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.UploadID, + &i.ExpiresAt, + &i.AccessCount, + &i.RequiresAuth, + &i.CreatedAt, + ) + return i, err +} + +const getSharesByUploadID = `-- name: GetSharesByUploadID :many +SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares WHERE upload_id = ? +` + +func (q *Queries) GetSharesByUploadID(ctx context.Context, uploadID string) ([]Share, error) { + rows, err := q.db.QueryContext(ctx, getSharesByUploadID, uploadID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Share + for rows.Next() { + var i Share + if err := rows.Scan( + &i.ID, + &i.CreatedByUserID, + &i.UploadID, + &i.ExpiresAt, + &i.AccessCount, + &i.RequiresAuth, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSharesByUserID = `-- name: GetSharesByUserID :many +SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares WHERE created_by_user_id = ? +` + +func (q *Queries) GetSharesByUserID(ctx context.Context, createdByUserID int64) ([]Share, error) { + rows, err := q.db.QueryContext(ctx, getSharesByUserID, createdByUserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Share + for rows.Next() { + var i Share + if err := rows.Scan( + &i.ID, + &i.CreatedByUserID, + &i.UploadID, + &i.ExpiresAt, + &i.AccessCount, + &i.RequiresAuth, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateShareAccessCount = `-- name: UpdateShareAccessCount :one +UPDATE shares +SET access_count = access_count + 1 +WHERE id = ? +RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at +` + +func (q *Queries) UpdateShareAccessCount(ctx context.Context, id string) (Share, error) { + row := q.db.QueryRowContext(ctx, updateShareAccessCount, id) + var i Share + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.UploadID, + &i.ExpiresAt, + &i.AccessCount, + &i.RequiresAuth, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/db/gen/targets.sql.go b/internal/db/gen/targets.sql.go index fd65f41..6a83014 100644 --- a/internal/db/gen/targets.sql.go +++ b/internal/db/gen/targets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: targets.sql package db diff --git a/internal/db/gen/uploads.sql.go b/internal/db/gen/uploads.sql.go index 4262629..a6ba527 100644 --- a/internal/db/gen/uploads.sql.go +++ b/internal/db/gen/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: uploads.sql package db diff --git a/internal/db/gen/users.sql.go b/internal/db/gen/users.sql.go index afffe5c..b1d88a5 100644 --- a/internal/db/gen/users.sql.go +++ b/internal/db/gen/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.31.1 // source: users.sql package db diff --git a/internal/db/migrations/00016_add_shares.sql b/internal/db/migrations/00016_add_shares.sql new file mode 100644 index 0000000..94fea85 --- /dev/null +++ b/internal/db/migrations/00016_add_shares.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS shares ( + id TEXT PRIMARY KEY, + created_by_user_id INTEGER NOT NULL, + upload_id TEXT NOT NULL, + expires_at DATETIME, + access_count INTEGER NOT NULL DEFAULT 0, + requires_auth TEXT NOT NULL DEFAULT 'none' CHECK (requires_auth IN ('none', 'bcc')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by_user_id) REFERENCES users(id), + FOREIGN KEY (upload_id) REFERENCES uploads(id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS shares; +-- +goose StatementEnd diff --git a/internal/db/queries/shares.sql b/internal/db/queries/shares.sql new file mode 100644 index 0000000..53e3214 --- /dev/null +++ b/internal/db/queries/shares.sql @@ -0,0 +1,30 @@ +-- name: CreateShare :one +INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetShareByID :one +SELECT * FROM shares WHERE id = ?; + +-- name: GetSharesByUploadID :many +SELECT * FROM shares WHERE upload_id = ?; + +-- name: GetSharesByUserID :many +SELECT * FROM shares WHERE created_by_user_id = ?; + +-- name: UpdateShareAccessCount :one +UPDATE shares +SET access_count = access_count + 1 +WHERE id = ? +RETURNING *; + +-- name: DeleteShare :exec +DELETE FROM shares WHERE id = ?; + +-- name: DeleteExpiredShares :exec +DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP; + +-- name: GetActiveShareByID :one +SELECT * FROM shares +WHERE id = ? + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP); From 728922e2b09fe878352593ecaf4f8cd499cf656d Mon Sep 17 00:00:00 2001 From: JWSametz Date: Tue, 11 Aug 2026 15:11:06 +0200 Subject: [PATCH 02/38] feat(api): add share creation endpoint --- internal/api/shares.go | 139 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/api/shares.go diff --git a/internal/api/shares.go b/internal/api/shares.go new file mode 100644 index 0000000..2a1c90b --- /dev/null +++ b/internal/api/shares.go @@ -0,0 +1,139 @@ +package api + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "net/http" + "time" + + "filebox/internal/auth" + db "filebox/internal/db/gen" +) + +type CreateShareRequest struct { + UploadID string `json:"uploadId"` + ExpiresInDays *int `json:"expiresInDays"` + RequiresAuth string `json:"requiresAuth"` +} + +type CreateShareResponse struct { + ShareID string `json:"shareId"` + ShareURL string `json:"shareUrl"` + ExpiresAt *string `json:"expiresAt"` +} + +type ShareResponse struct { + ID string `json:"id"` + UploadID string `json:"uploadId"` + CreatedByID int64 `json:"createdById"` + ExpiresAt *string `json:"expiresAt"` + AccessCount int64 `json:"accessCount"` + RequiresAuth string `json:"requiresAuth"` + CreatedAt string `json:"createdAt"` +} + +func toShareResponse(s db.Share) ShareResponse { + var expiresAt *string + if s.ExpiresAt.Valid { + expiresAt = new(s.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")) + } + return ShareResponse{ + ID: s.ID, + UploadID: s.UploadID, + CreatedByID: s.CreatedByUserID, + ExpiresAt: expiresAt, + AccessCount: s.AccessCount, + RequiresAuth: s.RequiresAuth, + CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// CreateShare creates a new share +func (h *Handlers) CreateShare(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + caller := auth.CallerFrom(r.Context()) + if caller == nil { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + + var req CreateShareRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.UploadID == "" { + writeJSONError(w, http.StatusBadRequest, "uploadId is required") + return + } + + if req.RequiresAuth == "" { + req.RequiresAuth = "none" + } + + shareID, err := generateShareID() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to generate share id") + return + } + + var expiresAt sql.NullTime + var expiresAtStr *string + if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 { + expTime := time.Now().AddDate(0, 0, *req.ExpiresInDays) + expiresAt = sql.NullTime{Time: expTime, Valid: true} + expiresAtStr = new(expTime.Format("2006-01-02T15:04:05Z")) + } + + params := db.CreateShareParams{ + ID: shareID, + CreatedByUserID: caller.UserID, + UploadID: req.UploadID, + ExpiresAt: expiresAt, + RequiresAuth: req.RequiresAuth, + } + + share, err := h.queries.CreateShare(r.Context(), params) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to create share") + return + } + + writeJSON(w, http.StatusCreated, CreateShareResponse{ + ShareID: share.ID, + ShareURL: shareID, + ExpiresAt: expiresAtStr, + }) +} + +func generateShareID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// GetShare retrieves a share by ID +func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { + // TODO: Implement +} + +// ListSharesByUpload lists all shares for an upload +func (h *Handlers) ListSharesByUpload(w http.ResponseWriter, r *http.Request) { + // TODO: Implement +} + +// ListSharesByUser lists all shares created by a user +func (h *Handlers) ListSharesByUser(w http.ResponseWriter, r *http.Request) { + // TODO: Implement +} + +// DeleteShare deletes a share +func (h *Handlers) DeleteShare(w http.ResponseWriter, r *http.Request) { + // TODO: Implement +} From b58af840068aed174348ff3bf44573f7a7104639 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Tue, 11 Aug 2026 16:17:26 +0200 Subject: [PATCH 03/38] feat(api): add get enpoints for listing shares and get share --- internal/api/handlers.go | 7 +- internal/api/shares.go | 144 ++++++++++++++++++++++++++++++++- internal/db/gen/shares.sql.go | 66 +++++++++++++++ internal/db/queries/shares.sql | 11 +++ internal/server/server.go | 4 +- 5 files changed, 224 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 11e676e..2278079 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -11,11 +11,12 @@ import ( ) type Handlers struct { - queries *db.Queries + queries *db.Queries + uploadDir string } -func NewHandlers(queries *db.Queries) *Handlers { - return &Handlers{queries: queries} +func NewHandlers(queries *db.Queries, uploadDir string) *Handlers { + return &Handlers{queries: queries, uploadDir: uploadDir} } type UploadResponse struct { diff --git a/internal/api/shares.go b/internal/api/shares.go index 2a1c90b..ab83188 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -5,13 +5,22 @@ import ( "database/sql" "encoding/base64" "encoding/json" + "fmt" "net/http" + "os" + "path/filepath" + "strconv" "time" "filebox/internal/auth" db "filebox/internal/db/gen" ) +const ( + defaultSharesPageSize = 20 + maxSharesPageSize = 100 +) + type CreateShareRequest struct { UploadID string `json:"uploadId"` ExpiresInDays *int `json:"expiresInDays"` @@ -118,9 +127,55 @@ func generateShareID() (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -// GetShare retrieves a share by ID +// GetShare retrieves a share by ID and serves the underlying file, provided +// the share hasn't expired and, for shares requiring BCC auth, the caller is +// logged in via the "bcc" provider. func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { - // TODO: Implement + shareID := r.PathValue("id") + + share, err := h.queries.GetActiveShareByID(r.Context(), shareID) + if err != nil { + writeJSONError(w, http.StatusNotFound, "share not found or expired") + return + } + + if share.RequiresAuth == "bcc" { + caller := auth.CallerFrom(r.Context()) + if caller == nil || caller.Provider != "bcc" { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + } + + upload, err := h.queries.GetUpload(r.Context(), share.UploadID) + if err != nil { + writeJSONError(w, http.StatusNotFound, "file not found") + return + } + + // Mirrors the directory resolution in internal/tus's finalizeUpload: files + // land under the target's configured path, or uploadDir/RawMaterial when + // the upload has no target. + targetDir := filepath.Join(h.uploadDir, "RawMaterial") + if upload.TargetName.Valid && upload.TargetName.String != "" { + if target, err := h.queries.GetTargetByName(r.Context(), upload.TargetName.String); err == nil { + targetDir = target.Path + } + } + filePath := filepath.Join(targetDir, upload.Filename) + + if _, err := os.Stat(filePath); err != nil { + writeJSONError(w, http.StatusNotFound, "file not found on disk") + return + } + + if _, err := h.queries.UpdateShareAccessCount(r.Context(), shareID); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to record access") + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", upload.Filename)) + http.ServeFile(w, r, filePath) } // ListSharesByUpload lists all shares for an upload @@ -128,9 +183,90 @@ func (h *Handlers) ListSharesByUpload(w http.ResponseWriter, r *http.Request) { // TODO: Implement } -// ListSharesByUser lists all shares created by a user +type ShareListItem struct { + ShareID string `json:"shareId"` + UploadID string `json:"uploadId"` + Filename string `json:"filename"` + CreatedAt string `json:"createdAt"` + ExpiresAt *string `json:"expiresAt"` + IsExpired bool `json:"isExpired"` + AccessCount int64 `json:"accessCount"` +} + +type ListSharesResponse struct { + Shares []ShareListItem `json:"shares"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` +} + +// ListSharesByUser lists the shares created by the authenticated caller, paginated. func (h *Handlers) ListSharesByUser(w http.ResponseWriter, r *http.Request) { - // TODO: Implement + caller := auth.CallerFrom(r.Context()) + if caller == nil { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + + page := 1 + if raw := r.URL.Query().Get("page"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + page = v + } + } + + pageSize := defaultSharesPageSize + if raw := r.URL.Query().Get("pageSize"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + pageSize = v + } + } + if pageSize > maxSharesPageSize { + pageSize = maxSharesPageSize + } + + total, err := h.queries.CountSharesByUserID(r.Context(), caller.UserID) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to count shares") + return + } + + rows, err := h.queries.ListSharesByUserPaginated(r.Context(), db.ListSharesByUserPaginatedParams{ + CreatedByUserID: caller.UserID, + Limit: int64(pageSize), + Offset: int64((page - 1) * pageSize), + }) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to list shares") + return + } + + now := time.Now() + shares := make([]ShareListItem, len(rows)) + for i, row := range rows { + var expiresAt *string + isExpired := false + if row.ExpiresAt.Valid { + expiresAt = new(row.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")) + isExpired = row.ExpiresAt.Time.Before(now) + } + shares[i] = ShareListItem{ + ShareID: row.ShareID, + UploadID: row.UploadID, + Filename: row.Filename, + CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z"), + ExpiresAt: expiresAt, + IsExpired: isExpired, + AccessCount: row.AccessCount, + } + } + + writeJSON(w, http.StatusOK, ListSharesResponse{ + Shares: shares, + Page: page, + PageSize: pageSize, + Total: total, + }) } // DeleteShare deletes a share diff --git a/internal/db/gen/shares.sql.go b/internal/db/gen/shares.sql.go index 192d69a..ef9712a 100644 --- a/internal/db/gen/shares.sql.go +++ b/internal/db/gen/shares.sql.go @@ -8,8 +8,20 @@ package db import ( "context" "database/sql" + "time" ) +const countSharesByUserID = `-- name: CountSharesByUserID :one +SELECT COUNT(*) FROM shares WHERE created_by_user_id = ? +` + +func (q *Queries) CountSharesByUserID(ctx context.Context, createdByUserID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countSharesByUserID, createdByUserID) + var count int64 + err := row.Scan(&count) + return count, err +} + const createShare = `-- name: CreateShare :one INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth) VALUES (?, ?, ?, ?, ?) @@ -173,6 +185,60 @@ func (q *Queries) GetSharesByUserID(ctx context.Context, createdByUserID int64) return items, nil } +const listSharesByUserPaginated = `-- name: ListSharesByUserPaginated :many +SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count +FROM shares s +JOIN uploads u ON u.id = s.upload_id +WHERE s.created_by_user_id = ? +ORDER BY s.created_at DESC +LIMIT ? OFFSET ? +` + +type ListSharesByUserPaginatedParams struct { + CreatedByUserID int64 + Limit int64 + Offset int64 +} + +type ListSharesByUserPaginatedRow struct { + ShareID string + UploadID string + Filename string + CreatedAt time.Time + ExpiresAt sql.NullTime + AccessCount int64 +} + +func (q *Queries) ListSharesByUserPaginated(ctx context.Context, arg ListSharesByUserPaginatedParams) ([]ListSharesByUserPaginatedRow, error) { + rows, err := q.db.QueryContext(ctx, listSharesByUserPaginated, arg.CreatedByUserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSharesByUserPaginatedRow + for rows.Next() { + var i ListSharesByUserPaginatedRow + if err := rows.Scan( + &i.ShareID, + &i.UploadID, + &i.Filename, + &i.CreatedAt, + &i.ExpiresAt, + &i.AccessCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateShareAccessCount = `-- name: UpdateShareAccessCount :one UPDATE shares SET access_count = access_count + 1 diff --git a/internal/db/queries/shares.sql b/internal/db/queries/shares.sql index 53e3214..4b62dd5 100644 --- a/internal/db/queries/shares.sql +++ b/internal/db/queries/shares.sql @@ -12,6 +12,17 @@ SELECT * FROM shares WHERE upload_id = ?; -- name: GetSharesByUserID :many SELECT * FROM shares WHERE created_by_user_id = ?; +-- name: ListSharesByUserPaginated :many +SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count +FROM shares s +JOIN uploads u ON u.id = s.upload_id +WHERE s.created_by_user_id = ? +ORDER BY s.created_at DESC +LIMIT ? OFFSET ?; + +-- name: CountSharesByUserID :one +SELECT COUNT(*) FROM shares WHERE created_by_user_id = ?; + -- name: UpdateShareAccessCount :one UPDATE shares SET access_count = access_count + 1 diff --git a/internal/server/server.go b/internal/server/server.go index 187151f..c375460 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -164,13 +164,15 @@ func (s *Server) resolveUploadUserID(hook tushandler.HookEvent) (string, error) } func (s *Server) setupAPI(uploadDir string) { - h := api.NewHandlers(s.queries) + h := api.NewHandlers(s.queries, uploadDir) s.mux.HandleFunc("GET /api/targets", h.ListTargets) s.mux.HandleFunc("GET /api/projects", h.ListProjects) s.mux.HandleFunc("GET /api/projects/{code}/suggestions", h.ProjectSuggestions) s.mux.HandleFunc("GET /api/arrangements", h.ListArrangements) s.mux.HandleFunc("GET /api/arrangements/{code}/sub-events", h.ListSubEvents) s.mux.HandleFunc("GET /api/uploads", h.ListUploads) + s.mux.HandleFunc("GET /api/shares", h.ListSharesByUser) + s.mux.HandleFunc("GET /api/shares/{id}", h.GetShare) admin := api.NewAdminHandlers(s.queries, uploadDir) admin.Register(s.mux) From 3635dd5f5bc9702e045acb3cdc1e213eca334a75 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Tue, 11 Aug 2026 16:17:59 +0200 Subject: [PATCH 04/38] feat(api): add share revoking endpoint --- internal/api/shares.go | 28 ++++++++++++++++++++++++++-- internal/server/server.go | 1 + 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/api/shares.go b/internal/api/shares.go index ab83188..4fde6d2 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -269,7 +269,31 @@ func (h *Handlers) ListSharesByUser(w http.ResponseWriter, r *http.Request) { }) } -// DeleteShare deletes a share +// DeleteShare revokes a share. Only the share's creator may delete it. func (h *Handlers) DeleteShare(w http.ResponseWriter, r *http.Request) { - // TODO: Implement + caller := auth.CallerFrom(r.Context()) + if caller == nil { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + + shareID := r.PathValue("id") + + share, err := h.queries.GetShareByID(r.Context(), shareID) + if err != nil { + writeJSONError(w, http.StatusNotFound, "share not found") + return + } + + if share.CreatedByUserID != caller.UserID { + writeJSONError(w, http.StatusForbidden, "not authorized to delete this share") + return + } + + if err := h.queries.DeleteShare(r.Context(), shareID); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to delete share") + return + } + + w.WriteHeader(http.StatusNoContent) } diff --git a/internal/server/server.go b/internal/server/server.go index c375460..a2c3d67 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -173,6 +173,7 @@ func (s *Server) setupAPI(uploadDir string) { s.mux.HandleFunc("GET /api/uploads", h.ListUploads) s.mux.HandleFunc("GET /api/shares", h.ListSharesByUser) s.mux.HandleFunc("GET /api/shares/{id}", h.GetShare) + s.mux.HandleFunc("DELETE /api/shares/{id}", h.DeleteShare) admin := api.NewAdminHandlers(s.queries, uploadDir) admin.Register(s.mux) From 6c5789cfa55a10969805f77d2ee38743ad706a0a Mon Sep 17 00:00:00 2001 From: JWSametz Date: Tue, 11 Aug 2026 16:28:24 +0200 Subject: [PATCH 05/38] feat(api): add list all share for an upload endpoint --- internal/api/shares.go | 34 ++++++++++++++++++++++++++++++++-- internal/server/server.go | 2 ++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/internal/api/shares.go b/internal/api/shares.go index 4fde6d2..3e23841 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -178,9 +178,39 @@ func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filePath) } -// ListSharesByUpload lists all shares for an upload +// ListSharesByUpload lists all shares for an upload. Only the upload's owner may view them. func (h *Handlers) ListSharesByUpload(w http.ResponseWriter, r *http.Request) { - // TODO: Implement + caller := auth.CallerFrom(r.Context()) + if caller == nil { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + + uploadID := r.PathValue("id") + + upload, err := h.queries.GetUpload(r.Context(), uploadID) + if err != nil { + writeJSONError(w, http.StatusNotFound, "upload not found") + return + } + + if upload.UserID != caller.CanonicalUserID() { + writeJSONError(w, http.StatusForbidden, "not authorized to view shares for this upload") + return + } + + shares, err := h.queries.GetSharesByUploadID(r.Context(), uploadID) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to list shares") + return + } + + result := make([]ShareResponse, len(shares)) + for i, s := range shares { + result[i] = toShareResponse(s) + } + + writeJSON(w, http.StatusOK, result) } type ShareListItem struct { diff --git a/internal/server/server.go b/internal/server/server.go index a2c3d67..f6343da 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -171,7 +171,9 @@ func (s *Server) setupAPI(uploadDir string) { s.mux.HandleFunc("GET /api/arrangements", h.ListArrangements) s.mux.HandleFunc("GET /api/arrangements/{code}/sub-events", h.ListSubEvents) s.mux.HandleFunc("GET /api/uploads", h.ListUploads) + s.mux.HandleFunc("GET /api/uploads/{id}/shares", h.ListSharesByUpload) s.mux.HandleFunc("GET /api/shares", h.ListSharesByUser) + s.mux.HandleFunc("POST /api/shares", h.CreateShare) s.mux.HandleFunc("GET /api/shares/{id}", h.GetShare) s.mux.HandleFunc("DELETE /api/shares/{id}", h.DeleteShare) From c504487b6ad2b5e6a8260df7997afff985d11fab Mon Sep 17 00:00:00 2001 From: JWSametz Date: Wed, 12 Aug 2026 10:20:53 +0200 Subject: [PATCH 06/38] feat(api): add max access count and mandatory expiry to share --- internal/api/shares.go | 138 +++++++++++------- internal/db/gen/models.go | 1 + internal/db/gen/shares.sql.go | 58 +++----- .../00017_add_shares_max_access_count.sql | 9 ++ internal/db/queries/shares.sql | 11 +- 5 files changed, 124 insertions(+), 93 deletions(-) create mode 100644 internal/db/migrations/00017_add_shares_max_access_count.sql diff --git a/internal/api/shares.go b/internal/api/shares.go index 3e23841..ff24ad7 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -19,28 +19,32 @@ import ( const ( defaultSharesPageSize = 20 maxSharesPageSize = 100 + maxShareExpiryDays = 30 ) type CreateShareRequest struct { - UploadID string `json:"uploadId"` - ExpiresInDays *int `json:"expiresInDays"` - RequiresAuth string `json:"requiresAuth"` + UploadID string `json:"uploadId"` + ExpiresInDays int `json:"expiresInDays"` + RequiresAuth string `json:"requiresAuth"` + MaxAccessCount *int `json:"maxAccessCount"` } type CreateShareResponse struct { - ShareID string `json:"shareId"` - ShareURL string `json:"shareUrl"` - ExpiresAt *string `json:"expiresAt"` + ShareID string `json:"shareId"` + ShareURL string `json:"shareUrl"` + ExpiresAt string `json:"expiresAt"` + MaxAccessCount *int64 `json:"maxAccessCount"` } type ShareResponse struct { - ID string `json:"id"` - UploadID string `json:"uploadId"` - CreatedByID int64 `json:"createdById"` - ExpiresAt *string `json:"expiresAt"` - AccessCount int64 `json:"accessCount"` - RequiresAuth string `json:"requiresAuth"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + UploadID string `json:"uploadId"` + CreatedByID int64 `json:"createdById"` + ExpiresAt *string `json:"expiresAt"` + AccessCount int64 `json:"accessCount"` + MaxAccessCount *int64 `json:"maxAccessCount"` + RequiresAuth string `json:"requiresAuth"` + CreatedAt string `json:"createdAt"` } func toShareResponse(s db.Share) ShareResponse { @@ -48,14 +52,19 @@ func toShareResponse(s db.Share) ShareResponse { if s.ExpiresAt.Valid { expiresAt = new(s.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")) } + var maxAccessCount *int64 + if s.MaxAccessCount.Valid { + maxAccessCount = new(s.MaxAccessCount.Int64) + } return ShareResponse{ - ID: s.ID, - UploadID: s.UploadID, - CreatedByID: s.CreatedByUserID, - ExpiresAt: expiresAt, - AccessCount: s.AccessCount, - RequiresAuth: s.RequiresAuth, - CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z"), + ID: s.ID, + UploadID: s.UploadID, + CreatedByID: s.CreatedByUserID, + ExpiresAt: expiresAt, + AccessCount: s.AccessCount, + MaxAccessCount: maxAccessCount, + RequiresAuth: s.RequiresAuth, + CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z"), } } @@ -80,6 +89,11 @@ func (h *Handlers) CreateShare(w http.ResponseWriter, r *http.Request) { return } + if req.ExpiresInDays <= 0 || req.ExpiresInDays > maxShareExpiryDays { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("expiresInDays is required and must be between 1 and %d", maxShareExpiryDays)) + return + } + if req.RequiresAuth == "" { req.RequiresAuth = "none" } @@ -90,20 +104,22 @@ func (h *Handlers) CreateShare(w http.ResponseWriter, r *http.Request) { return } - var expiresAt sql.NullTime - var expiresAtStr *string - if req.ExpiresInDays != nil && *req.ExpiresInDays > 0 { - expTime := time.Now().AddDate(0, 0, *req.ExpiresInDays) - expiresAt = sql.NullTime{Time: expTime, Valid: true} - expiresAtStr = new(expTime.Format("2006-01-02T15:04:05Z")) + expTime := time.Now().AddDate(0, 0, req.ExpiresInDays) + expiresAt := sql.NullTime{Time: expTime, Valid: true} + expiresAtStr := expTime.Format("2006-01-02T15:04:05Z") + + var maxAccessCount sql.NullInt64 + if req.MaxAccessCount != nil && *req.MaxAccessCount > 0 { + maxAccessCount = sql.NullInt64{Int64: int64(*req.MaxAccessCount), Valid: true} } params := db.CreateShareParams{ - ID: shareID, - CreatedByUserID: caller.UserID, - UploadID: req.UploadID, - ExpiresAt: expiresAt, - RequiresAuth: req.RequiresAuth, + ID: shareID, + CreatedByUserID: caller.UserID, + UploadID: req.UploadID, + ExpiresAt: expiresAt, + RequiresAuth: req.RequiresAuth, + MaxAccessCount: maxAccessCount, } share, err := h.queries.CreateShare(r.Context(), params) @@ -112,10 +128,16 @@ func (h *Handlers) CreateShare(w http.ResponseWriter, r *http.Request) { return } + var maxAccessCountResp *int64 + if share.MaxAccessCount.Valid { + maxAccessCountResp = new(share.MaxAccessCount.Int64) + } + writeJSON(w, http.StatusCreated, CreateShareResponse{ - ShareID: share.ID, - ShareURL: shareID, - ExpiresAt: expiresAtStr, + ShareID: share.ID, + ShareURL: shareID, + ExpiresAt: expiresAtStr, + MaxAccessCount: maxAccessCountResp, }) } @@ -133,9 +155,19 @@ func generateShareID() (string, error) { func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { shareID := r.PathValue("id") - share, err := h.queries.GetActiveShareByID(r.Context(), shareID) + share, err := h.queries.GetShareByID(r.Context(), shareID) if err != nil { - writeJSONError(w, http.StatusNotFound, "share not found or expired") + writeJSONError(w, http.StatusNotFound, "share not found") + return + } + + if share.ExpiresAt.Valid && !share.ExpiresAt.Time.After(time.Now()) { + writeJSONError(w, http.StatusGone, "share has expired") + return + } + + if share.MaxAccessCount.Valid && share.AccessCount >= share.MaxAccessCount.Int64 { + writeJSONError(w, http.StatusGone, "share access limit reached") return } @@ -214,13 +246,14 @@ func (h *Handlers) ListSharesByUpload(w http.ResponseWriter, r *http.Request) { } type ShareListItem struct { - ShareID string `json:"shareId"` - UploadID string `json:"uploadId"` - Filename string `json:"filename"` - CreatedAt string `json:"createdAt"` - ExpiresAt *string `json:"expiresAt"` - IsExpired bool `json:"isExpired"` - AccessCount int64 `json:"accessCount"` + ShareID string `json:"shareId"` + UploadID string `json:"uploadId"` + Filename string `json:"filename"` + CreatedAt string `json:"createdAt"` + ExpiresAt *string `json:"expiresAt"` + IsExpired bool `json:"isExpired"` + AccessCount int64 `json:"accessCount"` + MaxAccessCount *int64 `json:"maxAccessCount"` } type ListSharesResponse struct { @@ -280,14 +313,19 @@ func (h *Handlers) ListSharesByUser(w http.ResponseWriter, r *http.Request) { expiresAt = new(row.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")) isExpired = row.ExpiresAt.Time.Before(now) } + var maxAccessCount *int64 + if row.MaxAccessCount.Valid { + maxAccessCount = new(row.MaxAccessCount.Int64) + } shares[i] = ShareListItem{ - ShareID: row.ShareID, - UploadID: row.UploadID, - Filename: row.Filename, - CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z"), - ExpiresAt: expiresAt, - IsExpired: isExpired, - AccessCount: row.AccessCount, + ShareID: row.ShareID, + UploadID: row.UploadID, + Filename: row.Filename, + CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z"), + ExpiresAt: expiresAt, + IsExpired: isExpired, + AccessCount: row.AccessCount, + MaxAccessCount: maxAccessCount, } } diff --git a/internal/db/gen/models.go b/internal/db/gen/models.go index a22eaa7..317c579 100644 --- a/internal/db/gen/models.go +++ b/internal/db/gen/models.go @@ -65,6 +65,7 @@ type Share struct { AccessCount int64 RequiresAuth string CreatedAt time.Time + MaxAccessCount sql.NullInt64 } type SubEvent struct { diff --git a/internal/db/gen/shares.sql.go b/internal/db/gen/shares.sql.go index ef9712a..65d6ce9 100644 --- a/internal/db/gen/shares.sql.go +++ b/internal/db/gen/shares.sql.go @@ -23,9 +23,9 @@ func (q *Queries) CountSharesByUserID(ctx context.Context, createdByUserID int64 } const createShare = `-- name: CreateShare :one -INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth) -VALUES (?, ?, ?, ?, ?) -RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at +INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth, max_access_count) +VALUES (?, ?, ?, ?, ?, ?) +RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count ` type CreateShareParams struct { @@ -34,6 +34,7 @@ type CreateShareParams struct { UploadID string ExpiresAt sql.NullTime RequiresAuth string + MaxAccessCount sql.NullInt64 } func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) (Share, error) { @@ -43,6 +44,7 @@ func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) (Share arg.UploadID, arg.ExpiresAt, arg.RequiresAuth, + arg.MaxAccessCount, ) var i Share err := row.Scan( @@ -53,6 +55,7 @@ func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) (Share &i.AccessCount, &i.RequiresAuth, &i.CreatedAt, + &i.MaxAccessCount, ) return i, err } @@ -75,29 +78,8 @@ func (q *Queries) DeleteShare(ctx context.Context, id string) error { return err } -const getActiveShareByID = `-- name: GetActiveShareByID :one -SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares -WHERE id = ? - AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) -` - -func (q *Queries) GetActiveShareByID(ctx context.Context, id string) (Share, error) { - row := q.db.QueryRowContext(ctx, getActiveShareByID, id) - var i Share - err := row.Scan( - &i.ID, - &i.CreatedByUserID, - &i.UploadID, - &i.ExpiresAt, - &i.AccessCount, - &i.RequiresAuth, - &i.CreatedAt, - ) - return i, err -} - const getShareByID = `-- name: GetShareByID :one -SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares WHERE id = ? +SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count FROM shares WHERE id = ? ` func (q *Queries) GetShareByID(ctx context.Context, id string) (Share, error) { @@ -111,12 +93,13 @@ func (q *Queries) GetShareByID(ctx context.Context, id string) (Share, error) { &i.AccessCount, &i.RequiresAuth, &i.CreatedAt, + &i.MaxAccessCount, ) return i, err } const getSharesByUploadID = `-- name: GetSharesByUploadID :many -SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares WHERE upload_id = ? +SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count FROM shares WHERE upload_id = ? ` func (q *Queries) GetSharesByUploadID(ctx context.Context, uploadID string) ([]Share, error) { @@ -136,6 +119,7 @@ func (q *Queries) GetSharesByUploadID(ctx context.Context, uploadID string) ([]S &i.AccessCount, &i.RequiresAuth, &i.CreatedAt, + &i.MaxAccessCount, ); err != nil { return nil, err } @@ -151,7 +135,7 @@ func (q *Queries) GetSharesByUploadID(ctx context.Context, uploadID string) ([]S } const getSharesByUserID = `-- name: GetSharesByUserID :many -SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at FROM shares WHERE created_by_user_id = ? +SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count FROM shares WHERE created_by_user_id = ? ` func (q *Queries) GetSharesByUserID(ctx context.Context, createdByUserID int64) ([]Share, error) { @@ -171,6 +155,7 @@ func (q *Queries) GetSharesByUserID(ctx context.Context, createdByUserID int64) &i.AccessCount, &i.RequiresAuth, &i.CreatedAt, + &i.MaxAccessCount, ); err != nil { return nil, err } @@ -186,7 +171,7 @@ func (q *Queries) GetSharesByUserID(ctx context.Context, createdByUserID int64) } const listSharesByUserPaginated = `-- name: ListSharesByUserPaginated :many -SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count +SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count, s.max_access_count FROM shares s JOIN uploads u ON u.id = s.upload_id WHERE s.created_by_user_id = ? @@ -201,12 +186,13 @@ type ListSharesByUserPaginatedParams struct { } type ListSharesByUserPaginatedRow struct { - ShareID string - UploadID string - Filename string - CreatedAt time.Time - ExpiresAt sql.NullTime - AccessCount int64 + ShareID string + UploadID string + Filename string + CreatedAt time.Time + ExpiresAt sql.NullTime + AccessCount int64 + MaxAccessCount sql.NullInt64 } func (q *Queries) ListSharesByUserPaginated(ctx context.Context, arg ListSharesByUserPaginatedParams) ([]ListSharesByUserPaginatedRow, error) { @@ -225,6 +211,7 @@ func (q *Queries) ListSharesByUserPaginated(ctx context.Context, arg ListSharesB &i.CreatedAt, &i.ExpiresAt, &i.AccessCount, + &i.MaxAccessCount, ); err != nil { return nil, err } @@ -243,7 +230,7 @@ const updateShareAccessCount = `-- name: UpdateShareAccessCount :one UPDATE shares SET access_count = access_count + 1 WHERE id = ? -RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at +RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count ` func (q *Queries) UpdateShareAccessCount(ctx context.Context, id string) (Share, error) { @@ -257,6 +244,7 @@ func (q *Queries) UpdateShareAccessCount(ctx context.Context, id string) (Share, &i.AccessCount, &i.RequiresAuth, &i.CreatedAt, + &i.MaxAccessCount, ) return i, err } diff --git a/internal/db/migrations/00017_add_shares_max_access_count.sql b/internal/db/migrations/00017_add_shares_max_access_count.sql new file mode 100644 index 0000000..af72a0b --- /dev/null +++ b/internal/db/migrations/00017_add_shares_max_access_count.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE shares ADD COLUMN max_access_count INTEGER; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE shares DROP COLUMN max_access_count; +-- +goose StatementEnd diff --git a/internal/db/queries/shares.sql b/internal/db/queries/shares.sql index 4b62dd5..2eed49f 100644 --- a/internal/db/queries/shares.sql +++ b/internal/db/queries/shares.sql @@ -1,6 +1,6 @@ -- name: CreateShare :one -INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth) -VALUES (?, ?, ?, ?, ?) +INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth, max_access_count) +VALUES (?, ?, ?, ?, ?, ?) RETURNING *; -- name: GetShareByID :one @@ -13,7 +13,7 @@ SELECT * FROM shares WHERE upload_id = ?; SELECT * FROM shares WHERE created_by_user_id = ?; -- name: ListSharesByUserPaginated :many -SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count +SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count, s.max_access_count FROM shares s JOIN uploads u ON u.id = s.upload_id WHERE s.created_by_user_id = ? @@ -34,8 +34,3 @@ DELETE FROM shares WHERE id = ?; -- name: DeleteExpiredShares :exec DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP; - --- name: GetActiveShareByID :one -SELECT * FROM shares -WHERE id = ? - AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP); From ff3ec76a042a203a566f327b5ca4868ca8b23e66 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Wed, 12 Aug 2026 14:23:23 +0200 Subject: [PATCH 07/38] feat(api): replace standalone shares with packages for file sending --- go.mod | 3 +- go.sum | 14 +- internal/api/packages.go | 336 ++++++++++++++++ internal/api/shares.go | 327 ++------------- internal/db/gen/models.go | 38 +- internal/db/gen/packages.sql.go | 373 ++++++++++++++++++ internal/db/gen/shares.sql.go | 220 +---------- .../00016_add_packages_and_shares.sql | 52 +++ internal/db/migrations/00016_add_shares.sql | 19 - .../00017_add_shares_max_access_count.sql | 9 - internal/db/queries/packages.sql | 61 +++ internal/db/queries/shares.sql | 33 +- internal/server/server.go | 7 +- 13 files changed, 900 insertions(+), 592 deletions(-) create mode 100644 internal/api/packages.go create mode 100644 internal/db/gen/packages.sql.go create mode 100644 internal/db/migrations/00016_add_packages_and_shares.sql delete mode 100644 internal/db/migrations/00016_add_shares.sql delete mode 100644 internal/db/migrations/00017_add_shares_max_access_count.sql create mode 100644 internal/db/queries/packages.sql diff --git a/go.mod b/go.mod index 0f7d22c..769c048 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.27.0 github.com/tus/tusd/v2 v2.9.2 + golang.org/x/crypto v0.55.0 golang.org/x/oauth2 v0.36.0 modernc.org/sqlite v1.48.2 ) @@ -24,7 +25,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index d9e4229..8938366 100644 --- a/go.sum +++ b/go.sum @@ -40,21 +40,23 @@ github.com/tus/tusd/v2 v2.9.2 h1:Dd/Dh0CG7+/wom4lDQnnhca+1p5qVwgnbyEBacj1v7c= github.com/tus/tusd/v2 v2.9.2/go.mod h1:+a9uNLru2Qy+CUu7QUIshmQ+X0fLNw77eu8voKVxmgA= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/api/packages.go b/internal/api/packages.go new file mode 100644 index 0000000..ddf1896 --- /dev/null +++ b/internal/api/packages.go @@ -0,0 +1,336 @@ +package api + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "golang.org/x/crypto/bcrypt" + + "filebox/internal/auth" + db "filebox/internal/db/gen" +) + +const ( + defaultSharesPageSize = 20 + maxSharesPageSize = 100 + maxShareExpiryDays = 30 +) + +var validVerificationMethods = map[string]bool{ + "none": true, + "email_otp": true, + "magic_link": true, + "password": true, + "bcc_login": true, +} + +type CreatePackageRequest struct { + Name string `json:"name"` + Message string `json:"message"` + UploadIDs []string `json:"uploadIds"` + Recipients []string `json:"recipients"` + ExpiresInDays int `json:"expiresInDays"` + MaxDownloads *int `json:"maxDownloads"` + VerificationMethod string `json:"verificationMethod"` + Password string `json:"password"` + NotifyOnDownload bool `json:"notifyOnDownload"` +} + +type CreatePackageResponse struct { + PackageID string `json:"packageId"` + PackageURL string `json:"packageUrl"` + ExpiresAt string `json:"expiresAt"` +} + +// CreatePackage bundles one or more already-uploaded files into a single +// shareable package ("Send"). Each file becomes its own shares row +// (package_id set, requires_auth "none") since package-level verification +// gates access before any individual file is reached. +func (h *Handlers) CreatePackage(w http.ResponseWriter, r *http.Request) { + caller := auth.CallerFrom(r.Context()) + if caller == nil { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + + var req CreatePackageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.Name == "" { + writeJSONError(w, http.StatusBadRequest, "name is required") + return + } + + if len(req.UploadIDs) == 0 { + writeJSONError(w, http.StatusBadRequest, "at least one uploadId is required") + return + } + + if req.ExpiresInDays <= 0 || req.ExpiresInDays > maxShareExpiryDays { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("expiresInDays is required and must be between 1 and %d", maxShareExpiryDays)) + return + } + + if req.VerificationMethod == "" { + req.VerificationMethod = "none" + } + if !validVerificationMethods[req.VerificationMethod] { + writeJSONError(w, http.StatusBadRequest, "invalid verificationMethod") + return + } + + var passwordHash sql.NullString + if req.VerificationMethod == "password" { + if req.Password == "" { + writeJSONError(w, http.StatusBadRequest, "password is required for verificationMethod=password") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to hash password") + return + } + passwordHash = sql.NullString{String: string(hash), Valid: true} + } + + var maxDownloads sql.NullInt64 + if req.MaxDownloads != nil && *req.MaxDownloads > 0 { + maxDownloads = sql.NullInt64{Int64: int64(*req.MaxDownloads), Valid: true} + } + + // Verify ownership of every upload before creating anything, so a + // package is never partially created against a file the caller can't share. + uploads := make([]db.Upload, 0, len(req.UploadIDs)) + for _, uploadID := range req.UploadIDs { + upload, err := h.queries.GetUpload(r.Context(), uploadID) + if err != nil { + writeJSONError(w, http.StatusNotFound, fmt.Sprintf("upload %q not found", uploadID)) + return + } + if upload.UserID != caller.CanonicalUserID() { + writeJSONError(w, http.StatusForbidden, fmt.Sprintf("not authorized to share upload %q", uploadID)) + return + } + uploads = append(uploads, upload) + } + + packageID, err := generateShareID() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to generate package id") + return + } + + expTime := time.Now().AddDate(0, 0, req.ExpiresInDays) + + pkg, err := h.queries.CreatePackage(r.Context(), db.CreatePackageParams{ + ID: packageID, + CreatedByUserID: caller.UserID, + Name: req.Name, + Message: req.Message, + VerificationMethod: req.VerificationMethod, + PasswordHash: passwordHash, + ExpiresAt: expTime, + MaxDownloads: maxDownloads, + }) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to create package") + return + } + + for _, upload := range uploads { + shareID, err := generateShareID() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to generate share id") + return + } + // No expiry/access-limit set here: a share belonging to a package + // carries no policy of its own, since the UI only ever lets someone + // set expiry/limits on the package as a whole. GetShare enforces + // those against the package, not this row. + if _, err := h.queries.CreateShare(r.Context(), db.CreateShareParams{ + ID: shareID, + PackageID: packageID, + UploadID: upload.ID, + }); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to attach file to package") + return + } + } + + for _, email := range req.Recipients { + if email == "" { + continue + } + if _, err := h.queries.CreatePackageRecipient(r.Context(), db.CreatePackageRecipientParams{ + PackageID: packageID, + Email: email, + }); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to add recipient") + return + } + } + + writeJSON(w, http.StatusCreated, CreatePackageResponse{ + PackageID: pkg.ID, + PackageURL: packageID, + ExpiresAt: expTime.Format("2006-01-02T15:04:05Z"), + }) +} + +type PackageListItem struct { + PackageID string `json:"packageId"` + Name string `json:"name"` + Message string `json:"message"` + VerificationMethod string `json:"verificationMethod"` + Recipients []string `json:"recipients"` + FileCount int `json:"fileCount"` + TotalSize int64 `json:"totalSize"` + DownloadCount int64 `json:"downloadCount"` + MaxDownloads *int64 `json:"maxDownloads"` + ExpiresAt string `json:"expiresAt"` + IsExpired bool `json:"isExpired"` + IsDownloadLimitHit bool `json:"isDownloadLimitHit"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` +} + +type ListPackagesResponse struct { + Packages []PackageListItem `json:"packages"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` +} + +// ListPackagesByUser lists the packages created by the authenticated caller, +// paginated ("Sent packages"). +func (h *Handlers) ListPackagesByUser(w http.ResponseWriter, r *http.Request) { + caller := auth.CallerFrom(r.Context()) + if caller == nil { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + + page := 1 + if raw := r.URL.Query().Get("page"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + page = v + } + } + + pageSize := defaultSharesPageSize + if raw := r.URL.Query().Get("pageSize"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + pageSize = v + } + } + if pageSize > maxSharesPageSize { + pageSize = maxSharesPageSize + } + + total, err := h.queries.CountPackagesByUser(r.Context(), caller.UserID) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to count packages") + return + } + + rows, err := h.queries.ListPackagesByUser(r.Context(), db.ListPackagesByUserParams{ + CreatedByUserID: caller.UserID, + Limit: int64(pageSize), + Offset: int64((page - 1) * pageSize), + }) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to list packages") + return + } + + now := time.Now() + packages := make([]PackageListItem, len(rows)) + for i, row := range rows { + files, err := h.queries.ListSharesByPackageID(r.Context(), row.ID) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to list package files") + return + } + var totalSize int64 + for _, f := range files { + totalSize += f.Size + } + + recipientRows, err := h.queries.ListPackageRecipientsByPackageID(r.Context(), row.ID) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to list package recipients") + return + } + recipients := make([]string, len(recipientRows)) + for i, rec := range recipientRows { + recipients[i] = rec.Email + } + + var maxDownloads *int64 + if row.MaxDownloads.Valid { + maxDownloads = new(row.MaxDownloads.Int64) + } + + packages[i] = PackageListItem{ + PackageID: row.ID, + Name: row.Name, + Message: row.Message, + VerificationMethod: row.VerificationMethod, + Recipients: recipients, + FileCount: len(files), + TotalSize: totalSize, + DownloadCount: row.DownloadCount, + MaxDownloads: maxDownloads, + ExpiresAt: row.ExpiresAt.Format("2006-01-02T15:04:05Z"), + IsExpired: row.Status == "active" && row.ExpiresAt.Before(now), + IsDownloadLimitHit: row.MaxDownloads.Valid && row.DownloadCount >= row.MaxDownloads.Int64, + Status: row.Status, + CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + } + + writeJSON(w, http.StatusOK, ListPackagesResponse{ + Packages: packages, + Page: page, + PageSize: pageSize, + Total: total, + }) +} + +// RevokePackage revokes a package. Only the package's creator may revoke it. +// Revocation is a soft status flip, not a delete, so the package still shows +// up (as "revoked") in the creator's package list. +func (h *Handlers) RevokePackage(w http.ResponseWriter, r *http.Request) { + caller := auth.CallerFrom(r.Context()) + if caller == nil { + writeJSONError(w, http.StatusForbidden, "authentication required") + return + } + + packageID := r.PathValue("id") + + pkg, err := h.queries.GetPackageByID(r.Context(), packageID) + if err != nil { + writeJSONError(w, http.StatusNotFound, "package not found") + return + } + + if pkg.CreatedByUserID != caller.UserID { + writeJSONError(w, http.StatusForbidden, "not authorized to revoke this package") + return + } + + if err := h.queries.RevokePackage(r.Context(), packageID); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to revoke package") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/shares.go b/internal/api/shares.go index ff24ad7..7936a11 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -2,182 +2,49 @@ package api import ( "crypto/rand" - "database/sql" "encoding/base64" - "encoding/json" "fmt" "net/http" "os" "path/filepath" - "strconv" "time" - - "filebox/internal/auth" - db "filebox/internal/db/gen" -) - -const ( - defaultSharesPageSize = 20 - maxSharesPageSize = 100 - maxShareExpiryDays = 30 ) -type CreateShareRequest struct { - UploadID string `json:"uploadId"` - ExpiresInDays int `json:"expiresInDays"` - RequiresAuth string `json:"requiresAuth"` - MaxAccessCount *int `json:"maxAccessCount"` -} - -type CreateShareResponse struct { - ShareID string `json:"shareId"` - ShareURL string `json:"shareUrl"` - ExpiresAt string `json:"expiresAt"` - MaxAccessCount *int64 `json:"maxAccessCount"` -} - -type ShareResponse struct { - ID string `json:"id"` - UploadID string `json:"uploadId"` - CreatedByID int64 `json:"createdById"` - ExpiresAt *string `json:"expiresAt"` - AccessCount int64 `json:"accessCount"` - MaxAccessCount *int64 `json:"maxAccessCount"` - RequiresAuth string `json:"requiresAuth"` - CreatedAt string `json:"createdAt"` -} - -func toShareResponse(s db.Share) ShareResponse { - var expiresAt *string - if s.ExpiresAt.Valid { - expiresAt = new(s.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")) - } - var maxAccessCount *int64 - if s.MaxAccessCount.Valid { - maxAccessCount = new(s.MaxAccessCount.Int64) - } - return ShareResponse{ - ID: s.ID, - UploadID: s.UploadID, - CreatedByID: s.CreatedByUserID, - ExpiresAt: expiresAt, - AccessCount: s.AccessCount, - MaxAccessCount: maxAccessCount, - RequiresAuth: s.RequiresAuth, - CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z"), - } -} - -// CreateShare creates a new share -func (h *Handlers) CreateShare(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - caller := auth.CallerFrom(r.Context()) - if caller == nil { - writeJSONError(w, http.StatusForbidden, "authentication required") - return - } - - var req CreateShareRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid request") - return - } - - if req.UploadID == "" { - writeJSONError(w, http.StatusBadRequest, "uploadId is required") - return - } - - if req.ExpiresInDays <= 0 || req.ExpiresInDays > maxShareExpiryDays { - writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("expiresInDays is required and must be between 1 and %d", maxShareExpiryDays)) - return - } - - if req.RequiresAuth == "" { - req.RequiresAuth = "none" - } +// GetShare retrieves a share by ID and serves the underlying file. A share +// carries no policy of its own — expiry, download limits, and verification +// all live on its parent package, since that's the only place the UI ever +// lets someone set them. +func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { + shareID := r.PathValue("id") - shareID, err := generateShareID() + share, err := h.queries.GetShareByID(r.Context(), shareID) if err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to generate share id") + writeJSONError(w, http.StatusNotFound, "share not found") return } - expTime := time.Now().AddDate(0, 0, req.ExpiresInDays) - expiresAt := sql.NullTime{Time: expTime, Valid: true} - expiresAtStr := expTime.Format("2006-01-02T15:04:05Z") - - var maxAccessCount sql.NullInt64 - if req.MaxAccessCount != nil && *req.MaxAccessCount > 0 { - maxAccessCount = sql.NullInt64{Int64: int64(*req.MaxAccessCount), Valid: true} - } - - params := db.CreateShareParams{ - ID: shareID, - CreatedByUserID: caller.UserID, - UploadID: req.UploadID, - ExpiresAt: expiresAt, - RequiresAuth: req.RequiresAuth, - MaxAccessCount: maxAccessCount, - } - - share, err := h.queries.CreateShare(r.Context(), params) + pkg, err := h.queries.GetPackageByID(r.Context(), share.PackageID) if err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to create share") + writeJSONError(w, http.StatusNotFound, "share not found") return } - var maxAccessCountResp *int64 - if share.MaxAccessCount.Valid { - maxAccessCountResp = new(share.MaxAccessCount.Int64) - } - - writeJSON(w, http.StatusCreated, CreateShareResponse{ - ShareID: share.ID, - ShareURL: shareID, - ExpiresAt: expiresAtStr, - MaxAccessCount: maxAccessCountResp, - }) -} - -func generateShareID() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -// GetShare retrieves a share by ID and serves the underlying file, provided -// the share hasn't expired and, for shares requiring BCC auth, the caller is -// logged in via the "bcc" provider. -func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { - shareID := r.PathValue("id") - - share, err := h.queries.GetShareByID(r.Context(), shareID) - if err != nil { - writeJSONError(w, http.StatusNotFound, "share not found") + if pkg.Status != "active" { + writeJSONError(w, http.StatusGone, "package has been revoked") return } - if share.ExpiresAt.Valid && !share.ExpiresAt.Time.After(time.Now()) { - writeJSONError(w, http.StatusGone, "share has expired") + if !pkg.ExpiresAt.After(time.Now()) { + writeJSONError(w, http.StatusGone, "package has expired") return } - if share.MaxAccessCount.Valid && share.AccessCount >= share.MaxAccessCount.Int64 { - writeJSONError(w, http.StatusGone, "share access limit reached") + if pkg.MaxDownloads.Valid && pkg.DownloadCount >= pkg.MaxDownloads.Int64 { + writeJSONError(w, http.StatusGone, "package download limit reached") return } - if share.RequiresAuth == "bcc" { - caller := auth.CallerFrom(r.Context()) - if caller == nil || caller.Provider != "bcc" { - writeJSONError(w, http.StatusForbidden, "authentication required") - return - } - } + // TODO: package verification method (password/email_otp/magic_link/bcc_login) upload, err := h.queries.GetUpload(r.Context(), share.UploadID) if err != nil { @@ -201,7 +68,7 @@ func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { return } - if _, err := h.queries.UpdateShareAccessCount(r.Context(), shareID); err != nil { + if _, err := h.queries.IncrementPackageDownloadCount(r.Context(), share.PackageID); err != nil { writeJSONError(w, http.StatusInternalServerError, "failed to record access") return } @@ -210,158 +77,10 @@ func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filePath) } -// ListSharesByUpload lists all shares for an upload. Only the upload's owner may view them. -func (h *Handlers) ListSharesByUpload(w http.ResponseWriter, r *http.Request) { - caller := auth.CallerFrom(r.Context()) - if caller == nil { - writeJSONError(w, http.StatusForbidden, "authentication required") - return - } - - uploadID := r.PathValue("id") - - upload, err := h.queries.GetUpload(r.Context(), uploadID) - if err != nil { - writeJSONError(w, http.StatusNotFound, "upload not found") - return - } - - if upload.UserID != caller.CanonicalUserID() { - writeJSONError(w, http.StatusForbidden, "not authorized to view shares for this upload") - return - } - - shares, err := h.queries.GetSharesByUploadID(r.Context(), uploadID) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to list shares") - return - } - - result := make([]ShareResponse, len(shares)) - for i, s := range shares { - result[i] = toShareResponse(s) - } - - writeJSON(w, http.StatusOK, result) -} - -type ShareListItem struct { - ShareID string `json:"shareId"` - UploadID string `json:"uploadId"` - Filename string `json:"filename"` - CreatedAt string `json:"createdAt"` - ExpiresAt *string `json:"expiresAt"` - IsExpired bool `json:"isExpired"` - AccessCount int64 `json:"accessCount"` - MaxAccessCount *int64 `json:"maxAccessCount"` -} - -type ListSharesResponse struct { - Shares []ShareListItem `json:"shares"` - Page int `json:"page"` - PageSize int `json:"pageSize"` - Total int64 `json:"total"` -} - -// ListSharesByUser lists the shares created by the authenticated caller, paginated. -func (h *Handlers) ListSharesByUser(w http.ResponseWriter, r *http.Request) { - caller := auth.CallerFrom(r.Context()) - if caller == nil { - writeJSONError(w, http.StatusForbidden, "authentication required") - return - } - - page := 1 - if raw := r.URL.Query().Get("page"); raw != "" { - if v, err := strconv.Atoi(raw); err == nil && v > 0 { - page = v - } - } - - pageSize := defaultSharesPageSize - if raw := r.URL.Query().Get("pageSize"); raw != "" { - if v, err := strconv.Atoi(raw); err == nil && v > 0 { - pageSize = v - } - } - if pageSize > maxSharesPageSize { - pageSize = maxSharesPageSize - } - - total, err := h.queries.CountSharesByUserID(r.Context(), caller.UserID) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to count shares") - return - } - - rows, err := h.queries.ListSharesByUserPaginated(r.Context(), db.ListSharesByUserPaginatedParams{ - CreatedByUserID: caller.UserID, - Limit: int64(pageSize), - Offset: int64((page - 1) * pageSize), - }) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to list shares") - return - } - - now := time.Now() - shares := make([]ShareListItem, len(rows)) - for i, row := range rows { - var expiresAt *string - isExpired := false - if row.ExpiresAt.Valid { - expiresAt = new(row.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")) - isExpired = row.ExpiresAt.Time.Before(now) - } - var maxAccessCount *int64 - if row.MaxAccessCount.Valid { - maxAccessCount = new(row.MaxAccessCount.Int64) - } - shares[i] = ShareListItem{ - ShareID: row.ShareID, - UploadID: row.UploadID, - Filename: row.Filename, - CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z"), - ExpiresAt: expiresAt, - IsExpired: isExpired, - AccessCount: row.AccessCount, - MaxAccessCount: maxAccessCount, - } - } - - writeJSON(w, http.StatusOK, ListSharesResponse{ - Shares: shares, - Page: page, - PageSize: pageSize, - Total: total, - }) -} - -// DeleteShare revokes a share. Only the share's creator may delete it. -func (h *Handlers) DeleteShare(w http.ResponseWriter, r *http.Request) { - caller := auth.CallerFrom(r.Context()) - if caller == nil { - writeJSONError(w, http.StatusForbidden, "authentication required") - return - } - - shareID := r.PathValue("id") - - share, err := h.queries.GetShareByID(r.Context(), shareID) - if err != nil { - writeJSONError(w, http.StatusNotFound, "share not found") - return - } - - if share.CreatedByUserID != caller.UserID { - writeJSONError(w, http.StatusForbidden, "not authorized to delete this share") - return - } - - if err := h.queries.DeleteShare(r.Context(), shareID); err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to delete share") - return +func generateShareID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err } - - w.WriteHeader(http.StatusNoContent) + return base64.RawURLEncoding.EncodeToString(b), nil } diff --git a/internal/db/gen/models.go b/internal/db/gen/models.go index 317c579..a49d2cc 100644 --- a/internal/db/gen/models.go +++ b/internal/db/gen/models.go @@ -43,6 +43,32 @@ type GroupMember struct { Email string } +type Package struct { + ID string + CreatedByUserID int64 + Name string + Message string + VerificationMethod string + PasswordHash sql.NullString + ExpiresAt time.Time + MaxDownloads sql.NullInt64 + DownloadCount int64 + NotifyOnDownload int64 + Status string + CreatedAt time.Time +} + +type PackageRecipient struct { + ID int64 + PackageID string + Email string + OtpCodeHash sql.NullString + OtpExpiresAt sql.NullTime + MagicLinkToken sql.NullString + VerifiedAt sql.NullTime + CreatedAt time.Time +} + type Project struct { ID int64 Name string @@ -58,14 +84,10 @@ type Session struct { } type Share struct { - ID string - CreatedByUserID int64 - UploadID string - ExpiresAt sql.NullTime - AccessCount int64 - RequiresAuth string - CreatedAt time.Time - MaxAccessCount sql.NullInt64 + ID string + PackageID string + UploadID string + CreatedAt time.Time } type SubEvent struct { diff --git a/internal/db/gen/packages.sql.go b/internal/db/gen/packages.sql.go new file mode 100644 index 0000000..3cf31a9 --- /dev/null +++ b/internal/db/gen/packages.sql.go @@ -0,0 +1,373 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: packages.sql + +package db + +import ( + "context" + "database/sql" + "time" +) + +const countPackagesByUser = `-- name: CountPackagesByUser :one +SELECT COUNT(*) FROM packages WHERE created_by_user_id = ? +` + +func (q *Queries) CountPackagesByUser(ctx context.Context, createdByUserID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countPackagesByUser, createdByUserID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createPackage = `-- name: CreatePackage :one +INSERT INTO packages (id, created_by_user_id, name, message, verification_method, password_hash, expires_at, max_downloads) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, created_by_user_id, name, message, verification_method, password_hash, expires_at, max_downloads, download_count, notify_on_download, status, created_at +` + +type CreatePackageParams struct { + ID string + CreatedByUserID int64 + Name string + Message string + VerificationMethod string + PasswordHash sql.NullString + ExpiresAt time.Time + MaxDownloads sql.NullInt64 +} + +func (q *Queries) CreatePackage(ctx context.Context, arg CreatePackageParams) (Package, error) { + row := q.db.QueryRowContext(ctx, createPackage, + arg.ID, + arg.CreatedByUserID, + arg.Name, + arg.Message, + arg.VerificationMethod, + arg.PasswordHash, + arg.ExpiresAt, + arg.MaxDownloads, + ) + var i Package + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Name, + &i.Message, + &i.VerificationMethod, + &i.PasswordHash, + &i.ExpiresAt, + &i.MaxDownloads, + &i.DownloadCount, + &i.NotifyOnDownload, + &i.Status, + &i.CreatedAt, + ) + return i, err +} + +const createPackageRecipient = `-- name: CreatePackageRecipient :one +INSERT INTO package_recipients (package_id, email) +VALUES (?, ?) +RETURNING id, package_id, email, otp_code_hash, otp_expires_at, magic_link_token, verified_at, created_at +` + +type CreatePackageRecipientParams struct { + PackageID string + Email string +} + +func (q *Queries) CreatePackageRecipient(ctx context.Context, arg CreatePackageRecipientParams) (PackageRecipient, error) { + row := q.db.QueryRowContext(ctx, createPackageRecipient, arg.PackageID, arg.Email) + var i PackageRecipient + err := row.Scan( + &i.ID, + &i.PackageID, + &i.Email, + &i.OtpCodeHash, + &i.OtpExpiresAt, + &i.MagicLinkToken, + &i.VerifiedAt, + &i.CreatedAt, + ) + return i, err +} + +const getPackageByID = `-- name: GetPackageByID :one +SELECT id, created_by_user_id, name, message, verification_method, password_hash, expires_at, max_downloads, download_count, notify_on_download, status, created_at FROM packages WHERE id = ? +` + +func (q *Queries) GetPackageByID(ctx context.Context, id string) (Package, error) { + row := q.db.QueryRowContext(ctx, getPackageByID, id) + var i Package + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Name, + &i.Message, + &i.VerificationMethod, + &i.PasswordHash, + &i.ExpiresAt, + &i.MaxDownloads, + &i.DownloadCount, + &i.NotifyOnDownload, + &i.Status, + &i.CreatedAt, + ) + return i, err +} + +const getPackageRecipientByEmail = `-- name: GetPackageRecipientByEmail :one +SELECT id, package_id, email, otp_code_hash, otp_expires_at, magic_link_token, verified_at, created_at FROM package_recipients WHERE package_id = ? AND email = ? +` + +type GetPackageRecipientByEmailParams struct { + PackageID string + Email string +} + +func (q *Queries) GetPackageRecipientByEmail(ctx context.Context, arg GetPackageRecipientByEmailParams) (PackageRecipient, error) { + row := q.db.QueryRowContext(ctx, getPackageRecipientByEmail, arg.PackageID, arg.Email) + var i PackageRecipient + err := row.Scan( + &i.ID, + &i.PackageID, + &i.Email, + &i.OtpCodeHash, + &i.OtpExpiresAt, + &i.MagicLinkToken, + &i.VerifiedAt, + &i.CreatedAt, + ) + return i, err +} + +const getPackageRecipientByMagicLinkToken = `-- name: GetPackageRecipientByMagicLinkToken :one +SELECT id, package_id, email, otp_code_hash, otp_expires_at, magic_link_token, verified_at, created_at FROM package_recipients WHERE magic_link_token = ? +` + +func (q *Queries) GetPackageRecipientByMagicLinkToken(ctx context.Context, magicLinkToken sql.NullString) (PackageRecipient, error) { + row := q.db.QueryRowContext(ctx, getPackageRecipientByMagicLinkToken, magicLinkToken) + var i PackageRecipient + err := row.Scan( + &i.ID, + &i.PackageID, + &i.Email, + &i.OtpCodeHash, + &i.OtpExpiresAt, + &i.MagicLinkToken, + &i.VerifiedAt, + &i.CreatedAt, + ) + return i, err +} + +const incrementPackageDownloadCount = `-- name: IncrementPackageDownloadCount :one +UPDATE packages +SET download_count = download_count + 1 +WHERE id = ? +RETURNING id, created_by_user_id, name, message, verification_method, password_hash, expires_at, max_downloads, download_count, notify_on_download, status, created_at +` + +func (q *Queries) IncrementPackageDownloadCount(ctx context.Context, id string) (Package, error) { + row := q.db.QueryRowContext(ctx, incrementPackageDownloadCount, id) + var i Package + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Name, + &i.Message, + &i.VerificationMethod, + &i.PasswordHash, + &i.ExpiresAt, + &i.MaxDownloads, + &i.DownloadCount, + &i.NotifyOnDownload, + &i.Status, + &i.CreatedAt, + ) + return i, err +} + +const listPackageRecipientsByPackageID = `-- name: ListPackageRecipientsByPackageID :many +SELECT id, package_id, email, otp_code_hash, otp_expires_at, magic_link_token, verified_at, created_at FROM package_recipients WHERE package_id = ? ORDER BY id ASC +` + +func (q *Queries) ListPackageRecipientsByPackageID(ctx context.Context, packageID string) ([]PackageRecipient, error) { + rows, err := q.db.QueryContext(ctx, listPackageRecipientsByPackageID, packageID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PackageRecipient + for rows.Next() { + var i PackageRecipient + if err := rows.Scan( + &i.ID, + &i.PackageID, + &i.Email, + &i.OtpCodeHash, + &i.OtpExpiresAt, + &i.MagicLinkToken, + &i.VerifiedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPackagesByUser = `-- name: ListPackagesByUser :many +SELECT id, created_by_user_id, name, message, verification_method, password_hash, expires_at, max_downloads, download_count, notify_on_download, status, created_at FROM packages +WHERE created_by_user_id = ? +ORDER BY created_at DESC +LIMIT ? OFFSET ? +` + +type ListPackagesByUserParams struct { + CreatedByUserID int64 + Limit int64 + Offset int64 +} + +func (q *Queries) ListPackagesByUser(ctx context.Context, arg ListPackagesByUserParams) ([]Package, error) { + rows, err := q.db.QueryContext(ctx, listPackagesByUser, arg.CreatedByUserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Package + for rows.Next() { + var i Package + if err := rows.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Name, + &i.Message, + &i.VerificationMethod, + &i.PasswordHash, + &i.ExpiresAt, + &i.MaxDownloads, + &i.DownloadCount, + &i.NotifyOnDownload, + &i.Status, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSharesByPackageID = `-- name: ListSharesByPackageID :many +SELECT s.id AS share_id, s.upload_id, u.filename, u.size +FROM shares s +JOIN uploads u ON u.id = s.upload_id +WHERE s.package_id = ? +ORDER BY s.created_at ASC +` + +type ListSharesByPackageIDRow struct { + ShareID string + UploadID string + Filename string + Size int64 +} + +func (q *Queries) ListSharesByPackageID(ctx context.Context, packageID string) ([]ListSharesByPackageIDRow, error) { + rows, err := q.db.QueryContext(ctx, listSharesByPackageID, packageID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSharesByPackageIDRow + for rows.Next() { + var i ListSharesByPackageIDRow + if err := rows.Scan( + &i.ShareID, + &i.UploadID, + &i.Filename, + &i.Size, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markPackageRecipientVerified = `-- name: MarkPackageRecipientVerified :exec +UPDATE package_recipients +SET verified_at = CURRENT_TIMESTAMP +WHERE id = ? +` + +func (q *Queries) MarkPackageRecipientVerified(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, markPackageRecipientVerified, id) + return err +} + +const revokePackage = `-- name: RevokePackage :exec +UPDATE packages SET status = 'revoked' WHERE id = ? +` + +func (q *Queries) RevokePackage(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, revokePackage, id) + return err +} + +const setPackageRecipientMagicLinkToken = `-- name: SetPackageRecipientMagicLinkToken :exec +UPDATE package_recipients +SET magic_link_token = ? +WHERE id = ? +` + +type SetPackageRecipientMagicLinkTokenParams struct { + MagicLinkToken sql.NullString + ID int64 +} + +func (q *Queries) SetPackageRecipientMagicLinkToken(ctx context.Context, arg SetPackageRecipientMagicLinkTokenParams) error { + _, err := q.db.ExecContext(ctx, setPackageRecipientMagicLinkToken, arg.MagicLinkToken, arg.ID) + return err +} + +const updatePackageRecipientOTP = `-- name: UpdatePackageRecipientOTP :exec +UPDATE package_recipients +SET otp_code_hash = ?, otp_expires_at = ? +WHERE id = ? +` + +type UpdatePackageRecipientOTPParams struct { + OtpCodeHash sql.NullString + OtpExpiresAt sql.NullTime + ID int64 +} + +func (q *Queries) UpdatePackageRecipientOTP(ctx context.Context, arg UpdatePackageRecipientOTPParams) error { + _, err := q.db.ExecContext(ctx, updatePackageRecipientOTP, arg.OtpCodeHash, arg.OtpExpiresAt, arg.ID) + return err +} diff --git a/internal/db/gen/shares.sql.go b/internal/db/gen/shares.sql.go index 65d6ce9..e2fa5ad 100644 --- a/internal/db/gen/shares.sql.go +++ b/internal/db/gen/shares.sql.go @@ -7,79 +7,34 @@ package db import ( "context" - "database/sql" - "time" ) -const countSharesByUserID = `-- name: CountSharesByUserID :one -SELECT COUNT(*) FROM shares WHERE created_by_user_id = ? -` - -func (q *Queries) CountSharesByUserID(ctx context.Context, createdByUserID int64) (int64, error) { - row := q.db.QueryRowContext(ctx, countSharesByUserID, createdByUserID) - var count int64 - err := row.Scan(&count) - return count, err -} - const createShare = `-- name: CreateShare :one -INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth, max_access_count) -VALUES (?, ?, ?, ?, ?, ?) -RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count +INSERT INTO shares (id, package_id, upload_id) +VALUES (?, ?, ?) +RETURNING id, package_id, upload_id, created_at ` type CreateShareParams struct { - ID string - CreatedByUserID int64 - UploadID string - ExpiresAt sql.NullTime - RequiresAuth string - MaxAccessCount sql.NullInt64 + ID string + PackageID string + UploadID string } func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) (Share, error) { - row := q.db.QueryRowContext(ctx, createShare, - arg.ID, - arg.CreatedByUserID, - arg.UploadID, - arg.ExpiresAt, - arg.RequiresAuth, - arg.MaxAccessCount, - ) + row := q.db.QueryRowContext(ctx, createShare, arg.ID, arg.PackageID, arg.UploadID) var i Share err := row.Scan( &i.ID, - &i.CreatedByUserID, + &i.PackageID, &i.UploadID, - &i.ExpiresAt, - &i.AccessCount, - &i.RequiresAuth, &i.CreatedAt, - &i.MaxAccessCount, ) return i, err } -const deleteExpiredShares = `-- name: DeleteExpiredShares :exec -DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP -` - -func (q *Queries) DeleteExpiredShares(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, deleteExpiredShares) - return err -} - -const deleteShare = `-- name: DeleteShare :exec -DELETE FROM shares WHERE id = ? -` - -func (q *Queries) DeleteShare(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteShare, id) - return err -} - const getShareByID = `-- name: GetShareByID :one -SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count FROM shares WHERE id = ? +SELECT id, package_id, upload_id, created_at FROM shares WHERE id = ? ` func (q *Queries) GetShareByID(ctx context.Context, id string) (Share, error) { @@ -87,164 +42,9 @@ func (q *Queries) GetShareByID(ctx context.Context, id string) (Share, error) { var i Share err := row.Scan( &i.ID, - &i.CreatedByUserID, - &i.UploadID, - &i.ExpiresAt, - &i.AccessCount, - &i.RequiresAuth, - &i.CreatedAt, - &i.MaxAccessCount, - ) - return i, err -} - -const getSharesByUploadID = `-- name: GetSharesByUploadID :many -SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count FROM shares WHERE upload_id = ? -` - -func (q *Queries) GetSharesByUploadID(ctx context.Context, uploadID string) ([]Share, error) { - rows, err := q.db.QueryContext(ctx, getSharesByUploadID, uploadID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Share - for rows.Next() { - var i Share - if err := rows.Scan( - &i.ID, - &i.CreatedByUserID, - &i.UploadID, - &i.ExpiresAt, - &i.AccessCount, - &i.RequiresAuth, - &i.CreatedAt, - &i.MaxAccessCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getSharesByUserID = `-- name: GetSharesByUserID :many -SELECT id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count FROM shares WHERE created_by_user_id = ? -` - -func (q *Queries) GetSharesByUserID(ctx context.Context, createdByUserID int64) ([]Share, error) { - rows, err := q.db.QueryContext(ctx, getSharesByUserID, createdByUserID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Share - for rows.Next() { - var i Share - if err := rows.Scan( - &i.ID, - &i.CreatedByUserID, - &i.UploadID, - &i.ExpiresAt, - &i.AccessCount, - &i.RequiresAuth, - &i.CreatedAt, - &i.MaxAccessCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listSharesByUserPaginated = `-- name: ListSharesByUserPaginated :many -SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count, s.max_access_count -FROM shares s -JOIN uploads u ON u.id = s.upload_id -WHERE s.created_by_user_id = ? -ORDER BY s.created_at DESC -LIMIT ? OFFSET ? -` - -type ListSharesByUserPaginatedParams struct { - CreatedByUserID int64 - Limit int64 - Offset int64 -} - -type ListSharesByUserPaginatedRow struct { - ShareID string - UploadID string - Filename string - CreatedAt time.Time - ExpiresAt sql.NullTime - AccessCount int64 - MaxAccessCount sql.NullInt64 -} - -func (q *Queries) ListSharesByUserPaginated(ctx context.Context, arg ListSharesByUserPaginatedParams) ([]ListSharesByUserPaginatedRow, error) { - rows, err := q.db.QueryContext(ctx, listSharesByUserPaginated, arg.CreatedByUserID, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListSharesByUserPaginatedRow - for rows.Next() { - var i ListSharesByUserPaginatedRow - if err := rows.Scan( - &i.ShareID, - &i.UploadID, - &i.Filename, - &i.CreatedAt, - &i.ExpiresAt, - &i.AccessCount, - &i.MaxAccessCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateShareAccessCount = `-- name: UpdateShareAccessCount :one -UPDATE shares -SET access_count = access_count + 1 -WHERE id = ? -RETURNING id, created_by_user_id, upload_id, expires_at, access_count, requires_auth, created_at, max_access_count -` - -func (q *Queries) UpdateShareAccessCount(ctx context.Context, id string) (Share, error) { - row := q.db.QueryRowContext(ctx, updateShareAccessCount, id) - var i Share - err := row.Scan( - &i.ID, - &i.CreatedByUserID, + &i.PackageID, &i.UploadID, - &i.ExpiresAt, - &i.AccessCount, - &i.RequiresAuth, &i.CreatedAt, - &i.MaxAccessCount, ) return i, err } diff --git a/internal/db/migrations/00016_add_packages_and_shares.sql b/internal/db/migrations/00016_add_packages_and_shares.sql new file mode 100644 index 0000000..229f48f --- /dev/null +++ b/internal/db/migrations/00016_add_packages_and_shares.sql @@ -0,0 +1,52 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE packages ( + id TEXT PRIMARY KEY, + created_by_user_id INTEGER NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + verification_method TEXT NOT NULL DEFAULT 'none' CHECK (verification_method IN ('none', 'email_otp', 'magic_link', 'password', 'bcc_login')), + password_hash TEXT, + expires_at DATETIME NOT NULL, + max_downloads INTEGER, + download_count INTEGER NOT NULL DEFAULT 0, + notify_on_download INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE shares ( + id TEXT PRIMARY KEY, + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + upload_id TEXT NOT NULL REFERENCES uploads(id), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE package_recipients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + email TEXT NOT NULL, + otp_code_hash TEXT, + otp_expires_at DATETIME, + magic_link_token TEXT, + verified_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS package_recipients; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP TABLE IF EXISTS shares; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP TABLE IF EXISTS packages; +-- +goose StatementEnd diff --git a/internal/db/migrations/00016_add_shares.sql b/internal/db/migrations/00016_add_shares.sql deleted file mode 100644 index 94fea85..0000000 --- a/internal/db/migrations/00016_add_shares.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS shares ( - id TEXT PRIMARY KEY, - created_by_user_id INTEGER NOT NULL, - upload_id TEXT NOT NULL, - expires_at DATETIME, - access_count INTEGER NOT NULL DEFAULT 0, - requires_auth TEXT NOT NULL DEFAULT 'none' CHECK (requires_auth IN ('none', 'bcc')), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (created_by_user_id) REFERENCES users(id), - FOREIGN KEY (upload_id) REFERENCES uploads(id) -); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS shares; --- +goose StatementEnd diff --git a/internal/db/migrations/00017_add_shares_max_access_count.sql b/internal/db/migrations/00017_add_shares_max_access_count.sql deleted file mode 100644 index af72a0b..0000000 --- a/internal/db/migrations/00017_add_shares_max_access_count.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE shares ADD COLUMN max_access_count INTEGER; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE shares DROP COLUMN max_access_count; --- +goose StatementEnd diff --git a/internal/db/queries/packages.sql b/internal/db/queries/packages.sql new file mode 100644 index 0000000..2fb3653 --- /dev/null +++ b/internal/db/queries/packages.sql @@ -0,0 +1,61 @@ +-- name: CreatePackage :one +INSERT INTO packages (id, created_by_user_id, name, message, verification_method, password_hash, expires_at, max_downloads) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetPackageByID :one +SELECT * FROM packages WHERE id = ?; + +-- name: ListPackagesByUser :many +SELECT * FROM packages +WHERE created_by_user_id = ? +ORDER BY created_at DESC +LIMIT ? OFFSET ?; + +-- name: CountPackagesByUser :one +SELECT COUNT(*) FROM packages WHERE created_by_user_id = ?; + +-- name: RevokePackage :exec +UPDATE packages SET status = 'revoked' WHERE id = ?; + +-- name: IncrementPackageDownloadCount :one +UPDATE packages +SET download_count = download_count + 1 +WHERE id = ? +RETURNING *; + +-- name: ListSharesByPackageID :many +SELECT s.id AS share_id, s.upload_id, u.filename, u.size +FROM shares s +JOIN uploads u ON u.id = s.upload_id +WHERE s.package_id = ? +ORDER BY s.created_at ASC; + +-- name: CreatePackageRecipient :one +INSERT INTO package_recipients (package_id, email) +VALUES (?, ?) +RETURNING *; + +-- name: ListPackageRecipientsByPackageID :many +SELECT * FROM package_recipients WHERE package_id = ? ORDER BY id ASC; + +-- name: GetPackageRecipientByEmail :one +SELECT * FROM package_recipients WHERE package_id = ? AND email = ?; + +-- name: GetPackageRecipientByMagicLinkToken :one +SELECT * FROM package_recipients WHERE magic_link_token = ?; + +-- name: UpdatePackageRecipientOTP :exec +UPDATE package_recipients +SET otp_code_hash = ?, otp_expires_at = ? +WHERE id = ?; + +-- name: SetPackageRecipientMagicLinkToken :exec +UPDATE package_recipients +SET magic_link_token = ? +WHERE id = ?; + +-- name: MarkPackageRecipientVerified :exec +UPDATE package_recipients +SET verified_at = CURRENT_TIMESTAMP +WHERE id = ?; diff --git a/internal/db/queries/shares.sql b/internal/db/queries/shares.sql index 2eed49f..4990c1c 100644 --- a/internal/db/queries/shares.sql +++ b/internal/db/queries/shares.sql @@ -1,36 +1,7 @@ -- name: CreateShare :one -INSERT INTO shares (id, created_by_user_id, upload_id, expires_at, requires_auth, max_access_count) -VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO shares (id, package_id, upload_id) +VALUES (?, ?, ?) RETURNING *; -- name: GetShareByID :one SELECT * FROM shares WHERE id = ?; - --- name: GetSharesByUploadID :many -SELECT * FROM shares WHERE upload_id = ?; - --- name: GetSharesByUserID :many -SELECT * FROM shares WHERE created_by_user_id = ?; - --- name: ListSharesByUserPaginated :many -SELECT s.id AS share_id, s.upload_id, u.filename, s.created_at, s.expires_at, s.access_count, s.max_access_count -FROM shares s -JOIN uploads u ON u.id = s.upload_id -WHERE s.created_by_user_id = ? -ORDER BY s.created_at DESC -LIMIT ? OFFSET ?; - --- name: CountSharesByUserID :one -SELECT COUNT(*) FROM shares WHERE created_by_user_id = ?; - --- name: UpdateShareAccessCount :one -UPDATE shares -SET access_count = access_count + 1 -WHERE id = ? -RETURNING *; - --- name: DeleteShare :exec -DELETE FROM shares WHERE id = ?; - --- name: DeleteExpiredShares :exec -DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP; diff --git a/internal/server/server.go b/internal/server/server.go index f6343da..5800e75 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -171,11 +171,10 @@ func (s *Server) setupAPI(uploadDir string) { s.mux.HandleFunc("GET /api/arrangements", h.ListArrangements) s.mux.HandleFunc("GET /api/arrangements/{code}/sub-events", h.ListSubEvents) s.mux.HandleFunc("GET /api/uploads", h.ListUploads) - s.mux.HandleFunc("GET /api/uploads/{id}/shares", h.ListSharesByUpload) - s.mux.HandleFunc("GET /api/shares", h.ListSharesByUser) - s.mux.HandleFunc("POST /api/shares", h.CreateShare) s.mux.HandleFunc("GET /api/shares/{id}", h.GetShare) - s.mux.HandleFunc("DELETE /api/shares/{id}", h.DeleteShare) + s.mux.HandleFunc("GET /api/packages", h.ListPackagesByUser) + s.mux.HandleFunc("POST /api/packages", h.CreatePackage) + s.mux.HandleFunc("DELETE /api/packages/{id}", h.RevokePackage) admin := api.NewAdminHandlers(s.queries, uploadDir) admin.Register(s.mux) From 0b7a54775e866d0dd4066831172b7c49c8d1be69 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Wed, 12 Aug 2026 16:29:32 +0200 Subject: [PATCH 08/38] feat(api): add package verification and per-file download limits --- internal/api/package_delivery.go | 199 ++++++++++++++++++ internal/api/shares.go | 22 +- internal/db/gen/models.go | 16 +- internal/db/gen/package_verifications.sql.go | 57 +++++ internal/db/gen/shares.sql.go | 26 ++- .../00016_add_packages_and_shares.sql | 9 +- .../00017_add_package_verifications.sql | 14 ++ internal/db/queries/package_verifications.sql | 8 + internal/db/queries/shares.sql | 6 + internal/server/server.go | 2 + 10 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 internal/api/package_delivery.go create mode 100644 internal/db/gen/package_verifications.sql.go create mode 100644 internal/db/migrations/00017_add_package_verifications.sql create mode 100644 internal/db/queries/package_verifications.sql diff --git a/internal/api/package_delivery.go b/internal/api/package_delivery.go new file mode 100644 index 0000000..aebda48 --- /dev/null +++ b/internal/api/package_delivery.go @@ -0,0 +1,199 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" + + "filebox/internal/auth" + db "filebox/internal/db/gen" +) + +// packageVerified reports whether the caller of r has already proven +// whatever the package's verification_method requires. This is the single +// source of truth consulted both by GetPackagePreview (to decide whether to +// reveal the file list) and by GetShare (to gate the actual download) — +// duplicating this switch in both places would let them drift. +func (h *Handlers) packageVerified(r *http.Request, pkg db.Package) bool { + switch pkg.VerificationMethod { + case "none": + return true + case "bcc_login": + caller := auth.CallerFrom(r.Context()) + return caller != nil && caller.Provider == "bcc" + case "password": + cookie, err := r.Cookie("pkg_verify_" + pkg.ID) + if err != nil || cookie.Value == "" { + return false + } + _, err = h.queries.GetValidPackageVerification(r.Context(), db.GetValidPackageVerificationParams{ + ID: cookie.Value, + PackageID: pkg.ID, + }) + return err == nil + default: + // email_otp / magic_link: out of scope for now, nothing can satisfy them yet. + return false + } +} + +type packageFileView struct { + ShareID string `json:"shareId"` + Filename string `json:"filename"` + Size int64 `json:"size"` +} + +type packagePreviewResponse struct { + Name string `json:"name"` + Message string `json:"message"` + VerificationMethod string `json:"verificationMethod"` + ExpiresAt string `json:"expiresAt"` + MaxDownloads *int64 `json:"maxDownloads"` + DownloadCount int64 `json:"downloadCount"` + Verified bool `json:"verified"` + Files []packageFileView `json:"files,omitempty"` +} + +// GetPackagePreview is the public, unauthenticated endpoint a recipient's +// browser hits to see what a package contains. Before verification it only +// reveals enough to render a "verify" screen; the file list is withheld +// until packageVerified confirms the caller has satisfied the package's +// verification_method. +func (h *Handlers) GetPackagePreview(w http.ResponseWriter, r *http.Request) { + packageID := r.PathValue("id") + + pkg, err := h.queries.GetPackageByID(r.Context(), packageID) + if err != nil { + writeJSONError(w, http.StatusNotFound, "package not found") + return + } + + if pkg.Status != "active" { + writeJSONError(w, http.StatusGone, "package has been revoked") + return + } + + if !pkg.ExpiresAt.After(time.Now()) { + writeJSONError(w, http.StatusGone, "package has expired") + return + } + + // Note: max_downloads is intentionally NOT checked here. Viewing the + // preview is not a download, so it should still work even once the + // download limit has been hit — each individual file download is + // separately gated by GetShare. + + var maxDownloads *int64 + if pkg.MaxDownloads.Valid { + maxDownloads = &pkg.MaxDownloads.Int64 + } + + resp := packagePreviewResponse{ + Name: pkg.Name, + Message: pkg.Message, + VerificationMethod: pkg.VerificationMethod, + ExpiresAt: pkg.ExpiresAt.Format("2006-01-02T15:04:05Z"), + MaxDownloads: maxDownloads, + DownloadCount: pkg.DownloadCount, + Verified: h.packageVerified(r, pkg), + } + + if resp.Verified { + rows, err := h.queries.ListSharesByPackageID(r.Context(), pkg.ID) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to list package files") + return + } + files := make([]packageFileView, len(rows)) + for i, row := range rows { + files[i] = packageFileView{ + ShareID: row.ShareID, + Filename: row.Filename, + Size: row.Size, + } + } + resp.Files = files + } + + writeJSON(w, http.StatusOK, resp) +} + +type verifyPackageRequest struct { + Password string `json:"password"` +} + +// VerifyPackage is the public endpoint a recipient's browser posts a +// password to. On success it issues an opaque, DB-backed verification +// token as a per-package cookie, mirroring auth.SessionStore's pattern +// (opaque token + DB row, expiry checked at lookup time). The same cookie +// subsequently satisfies both GetPackagePreview and GetShare via +// packageVerified. +func (h *Handlers) VerifyPackage(w http.ResponseWriter, r *http.Request) { + packageID := r.PathValue("id") + + pkg, err := h.queries.GetPackageByID(r.Context(), packageID) + if err != nil { + writeJSONError(w, http.StatusNotFound, "package not found") + return + } + + if pkg.Status != "active" { + writeJSONError(w, http.StatusGone, "package has been revoked") + return + } + + if !pkg.ExpiresAt.After(time.Now()) { + writeJSONError(w, http.StatusGone, "package has expired") + return + } + + if pkg.VerificationMethod != "password" { + writeJSONError(w, http.StatusBadRequest, "package does not use password verification") + return + } + + var req verifyPackageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.Password == "" { + writeJSONError(w, http.StatusBadRequest, "password is required") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(pkg.PasswordHash.String), []byte(req.Password)); err != nil { + writeJSONError(w, http.StatusForbidden, "incorrect password") + return + } + + token, err := generateShareID() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to generate verification token") + return + } + + if _, err := h.queries.CreatePackageVerification(r.Context(), db.CreatePackageVerificationParams{ + ID: token, + PackageID: pkg.ID, + ExpiresAt: pkg.ExpiresAt, + }); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to record verification") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "pkg_verify_" + pkg.ID, + Value: token, + Path: "/", + Expires: pkg.ExpiresAt, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + writeJSON(w, http.StatusOK, map[string]bool{"verified": true}) +} diff --git a/internal/api/shares.go b/internal/api/shares.go index 7936a11..25637f1 100644 --- a/internal/api/shares.go +++ b/internal/api/shares.go @@ -39,12 +39,21 @@ func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { return } - if pkg.MaxDownloads.Valid && pkg.DownloadCount >= pkg.MaxDownloads.Int64 { - writeJSONError(w, http.StatusGone, "package download limit reached") + // max_downloads is a per-file budget, not a shared pool: it's configured + // once on the package (the UI only ever exposes one field), but checked + // against this specific share's own access_count. Otherwise downloading + // any N files across a multi-file package would permanently lock out + // every other file once the package-wide total hit the limit, even ones + // never touched. + if pkg.MaxDownloads.Valid && share.AccessCount >= pkg.MaxDownloads.Int64 { + writeJSONError(w, http.StatusGone, "file download limit reached") return } - // TODO: package verification method (password/email_otp/magic_link/bcc_login) + if !h.packageVerified(r, pkg) { + writeJSONError(w, http.StatusForbidden, "verification required") + return + } upload, err := h.queries.GetUpload(r.Context(), share.UploadID) if err != nil { @@ -68,6 +77,13 @@ func (h *Handlers) GetShare(w http.ResponseWriter, r *http.Request) { return } + // Two counters: the share's own access_count (used for the per-file gate + // above) and the package's download_count (an aggregate shown to the + // owner, e.g. "2/10 downloads" — display-only, not used for gating). + if _, err := h.queries.IncrementShareAccessCount(r.Context(), shareID); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to record access") + return + } if _, err := h.queries.IncrementPackageDownloadCount(r.Context(), share.PackageID); err != nil { writeJSONError(w, http.StatusInternalServerError, "failed to record access") return diff --git a/internal/db/gen/models.go b/internal/db/gen/models.go index a49d2cc..1e49d28 100644 --- a/internal/db/gen/models.go +++ b/internal/db/gen/models.go @@ -69,6 +69,13 @@ type PackageRecipient struct { CreatedAt time.Time } +type PackageVerification struct { + ID string + PackageID string + ExpiresAt time.Time + CreatedAt time.Time +} + type Project struct { ID int64 Name string @@ -84,10 +91,11 @@ type Session struct { } type Share struct { - ID string - PackageID string - UploadID string - CreatedAt time.Time + ID string + PackageID string + UploadID string + AccessCount int64 + CreatedAt time.Time } type SubEvent struct { diff --git a/internal/db/gen/package_verifications.sql.go b/internal/db/gen/package_verifications.sql.go new file mode 100644 index 0000000..1b33ba0 --- /dev/null +++ b/internal/db/gen/package_verifications.sql.go @@ -0,0 +1,57 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: package_verifications.sql + +package db + +import ( + "context" + "time" +) + +const createPackageVerification = `-- name: CreatePackageVerification :one +INSERT INTO package_verifications (id, package_id, expires_at) +VALUES (?, ?, ?) +RETURNING id, package_id, expires_at, created_at +` + +type CreatePackageVerificationParams struct { + ID string + PackageID string + ExpiresAt time.Time +} + +func (q *Queries) CreatePackageVerification(ctx context.Context, arg CreatePackageVerificationParams) (PackageVerification, error) { + row := q.db.QueryRowContext(ctx, createPackageVerification, arg.ID, arg.PackageID, arg.ExpiresAt) + var i PackageVerification + err := row.Scan( + &i.ID, + &i.PackageID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const getValidPackageVerification = `-- name: GetValidPackageVerification :one +SELECT id, package_id, expires_at, created_at FROM package_verifications +WHERE id = ? AND package_id = ? AND expires_at > CURRENT_TIMESTAMP +` + +type GetValidPackageVerificationParams struct { + ID string + PackageID string +} + +func (q *Queries) GetValidPackageVerification(ctx context.Context, arg GetValidPackageVerificationParams) (PackageVerification, error) { + row := q.db.QueryRowContext(ctx, getValidPackageVerification, arg.ID, arg.PackageID) + var i PackageVerification + err := row.Scan( + &i.ID, + &i.PackageID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/db/gen/shares.sql.go b/internal/db/gen/shares.sql.go index e2fa5ad..822742b 100644 --- a/internal/db/gen/shares.sql.go +++ b/internal/db/gen/shares.sql.go @@ -12,7 +12,7 @@ import ( const createShare = `-- name: CreateShare :one INSERT INTO shares (id, package_id, upload_id) VALUES (?, ?, ?) -RETURNING id, package_id, upload_id, created_at +RETURNING id, package_id, upload_id, access_count, created_at ` type CreateShareParams struct { @@ -28,13 +28,14 @@ func (q *Queries) CreateShare(ctx context.Context, arg CreateShareParams) (Share &i.ID, &i.PackageID, &i.UploadID, + &i.AccessCount, &i.CreatedAt, ) return i, err } const getShareByID = `-- name: GetShareByID :one -SELECT id, package_id, upload_id, created_at FROM shares WHERE id = ? +SELECT id, package_id, upload_id, access_count, created_at FROM shares WHERE id = ? ` func (q *Queries) GetShareByID(ctx context.Context, id string) (Share, error) { @@ -44,6 +45,27 @@ func (q *Queries) GetShareByID(ctx context.Context, id string) (Share, error) { &i.ID, &i.PackageID, &i.UploadID, + &i.AccessCount, + &i.CreatedAt, + ) + return i, err +} + +const incrementShareAccessCount = `-- name: IncrementShareAccessCount :one +UPDATE shares +SET access_count = access_count + 1 +WHERE id = ? +RETURNING id, package_id, upload_id, access_count, created_at +` + +func (q *Queries) IncrementShareAccessCount(ctx context.Context, id string) (Share, error) { + row := q.db.QueryRowContext(ctx, incrementShareAccessCount, id) + var i Share + err := row.Scan( + &i.ID, + &i.PackageID, + &i.UploadID, + &i.AccessCount, &i.CreatedAt, ) return i, err diff --git a/internal/db/migrations/00016_add_packages_and_shares.sql b/internal/db/migrations/00016_add_packages_and_shares.sql index 229f48f..cff1d4b 100644 --- a/internal/db/migrations/00016_add_packages_and_shares.sql +++ b/internal/db/migrations/00016_add_packages_and_shares.sql @@ -18,10 +18,11 @@ CREATE TABLE packages ( -- +goose StatementBegin CREATE TABLE shares ( - id TEXT PRIMARY KEY, - package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, - upload_id TEXT NOT NULL REFERENCES uploads(id), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + id TEXT PRIMARY KEY, + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + upload_id TEXT NOT NULL REFERENCES uploads(id), + access_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- +goose StatementEnd diff --git a/internal/db/migrations/00017_add_package_verifications.sql b/internal/db/migrations/00017_add_package_verifications.sql new file mode 100644 index 0000000..ef1165f --- /dev/null +++ b/internal/db/migrations/00017_add_package_verifications.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE package_verifications ( + id TEXT PRIMARY KEY, + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS package_verifications; +-- +goose StatementEnd diff --git a/internal/db/queries/package_verifications.sql b/internal/db/queries/package_verifications.sql new file mode 100644 index 0000000..58d7577 --- /dev/null +++ b/internal/db/queries/package_verifications.sql @@ -0,0 +1,8 @@ +-- name: CreatePackageVerification :one +INSERT INTO package_verifications (id, package_id, expires_at) +VALUES (?, ?, ?) +RETURNING *; + +-- name: GetValidPackageVerification :one +SELECT * FROM package_verifications +WHERE id = ? AND package_id = ? AND expires_at > CURRENT_TIMESTAMP; diff --git a/internal/db/queries/shares.sql b/internal/db/queries/shares.sql index 4990c1c..f095886 100644 --- a/internal/db/queries/shares.sql +++ b/internal/db/queries/shares.sql @@ -5,3 +5,9 @@ RETURNING *; -- name: GetShareByID :one SELECT * FROM shares WHERE id = ?; + +-- name: IncrementShareAccessCount :one +UPDATE shares +SET access_count = access_count + 1 +WHERE id = ? +RETURNING *; diff --git a/internal/server/server.go b/internal/server/server.go index 5800e75..d0284ab 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -175,6 +175,8 @@ func (s *Server) setupAPI(uploadDir string) { s.mux.HandleFunc("GET /api/packages", h.ListPackagesByUser) s.mux.HandleFunc("POST /api/packages", h.CreatePackage) s.mux.HandleFunc("DELETE /api/packages/{id}", h.RevokePackage) + s.mux.HandleFunc("GET /api/packages/{id}/preview", h.GetPackagePreview) + s.mux.HandleFunc("POST /api/packages/{id}/verify", h.VerifyPackage) admin := api.NewAdminHandlers(s.queries, uploadDir) admin.Register(s.mux) From abaf21f7f15e05735c612276a76a3b6d287be002 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Thu, 13 Aug 2026 14:55:48 +0200 Subject: [PATCH 09/38] feat(frontend): add initial package sending UI Sending, recipient link, and preview flows for packages. Known rough edges to be fixed --- frontend/src/App.vue | 6 +- frontend/src/assets/send.css | 340 ++++++++++++++++++ .../components/send/PackageComposeForm.vue | 236 ++++++++++++ .../components/send/PackageDownloadScreen.vue | 90 +++++ .../src/components/send/PackageFileRow.vue | 39 ++ .../components/send/PackageVerifyScreen.vue | 59 +++ .../components/send/RecipientChipInput.vue | 64 ++++ .../components/send/RecipientPreviewTab.vue | 85 +++++ frontend/src/components/send/SendUserMenu.vue | 46 +++ .../src/components/send/SentPackageCard.vue | 77 ++++ .../src/components/send/SentPackagesList.vue | 53 +++ .../send/VerificationMethodPicker.vue | 50 +++ frontend/src/composables/usePackagePreview.ts | 58 +++ frontend/src/composables/usePackages.ts | 113 ++++++ frontend/src/composables/useTusUpload.ts | 5 + frontend/src/router.ts | 12 + frontend/src/types/index.ts | 3 + frontend/src/views/RecipientPage.vue | 60 ++++ frontend/src/views/Send.vue | 63 ++++ 19 files changed, 1458 insertions(+), 1 deletion(-) create mode 100644 frontend/src/assets/send.css create mode 100644 frontend/src/components/send/PackageComposeForm.vue create mode 100644 frontend/src/components/send/PackageDownloadScreen.vue create mode 100644 frontend/src/components/send/PackageFileRow.vue create mode 100644 frontend/src/components/send/PackageVerifyScreen.vue create mode 100644 frontend/src/components/send/RecipientChipInput.vue create mode 100644 frontend/src/components/send/RecipientPreviewTab.vue create mode 100644 frontend/src/components/send/SendUserMenu.vue create mode 100644 frontend/src/components/send/SentPackageCard.vue create mode 100644 frontend/src/components/send/SentPackagesList.vue create mode 100644 frontend/src/components/send/VerificationMethodPicker.vue create mode 100644 frontend/src/composables/usePackagePreview.ts create mode 100644 frontend/src/composables/usePackages.ts create mode 100644 frontend/src/views/RecipientPage.vue create mode 100644 frontend/src/views/Send.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f5ca1e3..1272701 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,11 +1,15 @@ diff --git a/frontend/src/assets/send.css b/frontend/src/assets/send.css new file mode 100644 index 0000000..1dfd3ea --- /dev/null +++ b/frontend/src/assets/send.css @@ -0,0 +1,340 @@ +/* Design tokens + scoped Send/recipient styles. Anything outside the + `.send-root` subtree is unaffected — the rest of the app keeps its current + Tailwind look. Tokens and component classes come straight from the Claude + Design prototype (Send.html), mirroring the same pattern assets/admin.css + already established for Admin.vue. */ + +.send-root { + --bg: #0a1426; + --bg-2: #0f1c33; + --bg-3: #15243f; + --bg-4: #1a2a48; + --line: #1f2f4d; + --line-2: #2a3d61; + --ink: #e7ecf5; + --ink-2: #aeb8cc; + --ink-3: #6c7896; + --accent: oklch(0.72 0.10 250); + --accent-2: oklch(0.62 0.12 250); + --accent-ink: #0a1426; + --ok: oklch(0.74 0.12 160); + --warn: oklch(0.80 0.14 75); + --danger: oklch(0.70 0.16 25); + --info: oklch(0.70 0.13 240); + + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + background: var(--bg); + color: var(--ink); + min-height: 100vh; +} +.send-root *, .send-root *::before, .send-root *::after { box-sizing: border-box; } +.send-root ::selection { background: color-mix(in oklch, var(--accent), transparent 70%); color: var(--ink); } +.send-root button, .send-root input, .send-root select, .send-root textarea { font-family: inherit; color: inherit; } +.send-root a { color: var(--accent); text-decoration: none; } +.send-root a:hover { color: oklch(0.80 0.10 250); } +.send-root .mono { font-family: 'JetBrains Mono', monospace; } + +@keyframes send-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } +@keyframes send-pop { from { opacity: 0; transform: translateY(-4px) scale(0.98); } to { opacity: 1; transform: none; } } +.send-root .fb-fade { animation: send-fade .25s ease both; } +.send-root .fb-pop { animation: send-pop .15s ease both; } + +/* ============ Header ============ */ +.send-root .page-wrap { max-width: 920px; margin: 0 auto; padding: 36px 24px 90px; } +.send-root .header { display: flex; align-items: center; gap: 22px; margin-bottom: 30px; position: relative; } +.send-root .brand { display: inline-flex; align-items: center; gap: 12px; color: var(--ink); } +.send-root .brand .mark { width: 30px; height: 30px; color: var(--ink); } +.send-root .brand .name { font-size: 21px; font-weight: 600; letter-spacing: -0.3px; } +.send-root .nav { display: flex; gap: 4px; } +.send-root .nav a { + padding: 7px 14px; border-radius: 8px; font-size: 14px; font-weight: 500; + color: var(--ink-3); transition: color .15s, background .15s; +} +.send-root .nav a:hover { color: var(--ink-2); background: var(--bg-2); } +.send-root .nav a.active { color: var(--ink); background: var(--bg-3); } +.send-root .spacer { flex: 1; } + +.send-root .me-trigger { + display: inline-flex; align-items: center; gap: 10px; + padding: 4px 10px 4px 4px; + background: transparent; border: 1px solid transparent; + border-radius: 999px; cursor: pointer; font-size: 14px; color: var(--ink); + transition: background .15s ease, border-color .15s ease; +} +.send-root .me-trigger:hover { background: var(--bg-2); border-color: var(--line); } +.send-root .me-trigger.open { background: var(--bg-2); border-color: var(--line-2); } +.send-root .me-trigger .avatar { + width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; + font-size: 12px; font-weight: 600; color: #fff; background: oklch(0.62 0.16 250); flex-shrink: 0; +} +.send-root .me-menu { + position: absolute; right: 0; top: calc(100% + 8px); + width: 240px; padding: 6px; background: var(--bg-2); border: 1px solid var(--line-2); + border-radius: 12px; box-shadow: 0 18px 50px rgba(0,0,0,0.55); z-index: 20; +} +.send-root .me-menu .who { padding: 10px 12px 12px; border-bottom: 1px solid var(--line); margin-bottom: 4px; } +.send-root .me-menu .who .n { font-size: 14px; font-weight: 500; color: var(--ink); } +.send-root .me-menu .who .e { font-size: 13px; color: var(--ink-2); margin-top: 2px; } +.send-root .me-menu .who .o { font-size: 10.5px; color: var(--ink-3); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.1em; } +.send-root .me-menu a, .send-root .me-menu button { + display: block; width: 100%; text-align: left; padding: 8px 12px; border-radius: 6px; + background: transparent; border: none; color: var(--ink); font-size: 14px; cursor: pointer; +} +.send-root .me-menu a:hover, .send-root .me-menu button:hover { background: var(--bg-3); } + +/* ============ Page title + view tabs ============ */ +.send-root .page-title { margin: 0 0 4px; font-size: 26px; font-weight: 600; letter-spacing: -0.4px; } +.send-root .page-sub { margin: 0 0 24px; font-size: 14.5px; color: var(--ink-2); line-height: 1.5; } + +.send-root .view-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 26px; } +.send-root .view-tabs button { + background: transparent; border: none; color: var(--ink-3); cursor: pointer; + padding: 12px 4px; margin-right: 22px; font-size: 14.5px; font-weight: 500; + border-bottom: 2px solid transparent; margin-bottom: -1px; + display: inline-flex; align-items: center; gap: 8px; + transition: color .15s; +} +.send-root .view-tabs button:hover { color: var(--ink-2); } +.send-root .view-tabs button.active { color: var(--ink); border-bottom-color: var(--accent); } +.send-root .view-tabs button .count { + font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: var(--bg-3); color: var(--ink-2); border: 1px solid var(--line-2); +} +.send-root .view-tabs button.active .count { background: color-mix(in oklch, var(--accent), transparent 80%); color: var(--accent); border-color: transparent; } + +/* ============ Layout variant switcher ============ */ +.send-root .lay-row { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } +.send-root .lay-row .lab { font-size: 12.5px; color: var(--ink-3); } +.send-root .variant-seg { display: inline-flex; gap: 0; background: var(--bg-2); border: 1px solid var(--line-2); border-radius: 8px; padding: 3px; } +.send-root .variant-seg button { + background: transparent; border: none; color: var(--ink-3); cursor: pointer; + padding: 5px 12px; font-size: 12px; font-weight: 500; border-radius: 5px; + transition: color .12s, background .12s; +} +.send-root .variant-seg button:hover { color: var(--ink-2); } +.send-root .variant-seg button.active { background: var(--bg-4); color: var(--ink); } + +/* ============ Compose layout ============ */ +.send-root .compose { display: grid; grid-template-columns: 1fr 340px; gap: 22px; align-items: start; } +.send-root .col-main { display: flex; flex-direction: column; gap: 18px; min-width: 0; } +@media (max-width: 860px) { + .send-root .compose { display: block; max-width: 640px; } + .send-root .compose .compose-actions { display: flex; } + .send-root .compose .summary { display: none; } +} + +.send-root .block-label { font-size: 13.5px; font-weight: 600; color: var(--ink); margin-bottom: 9px; display: flex; align-items: center; gap: 8px; } +.send-root .block-label .num { + width: 22px; height: 22px; border-radius: 50%; background: var(--bg-3); border: 1px solid var(--line-2); + color: var(--ink-2); font-size: 11.5px; font-weight: 600; display: inline-grid; place-items: center; flex-shrink: 0; +} +.send-root .block-label .num.done { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +.send-root .block-label .opt { font-size: 10.5px; color: var(--ink-3); font-weight: 400; text-transform: uppercase; letter-spacing: 0.07em; margin-left: auto; } + +/* Dropzone */ +.send-root .dropzone { + border: 1.5px dashed var(--line-2); border-radius: 14px; padding: 40px 24px; + display: flex; flex-direction: column; align-items: center; text-align: center; cursor: pointer; + transition: border-color .15s ease, background .15s ease; +} +.send-root .dropzone:hover { border-color: color-mix(in oklch, var(--accent), transparent 40%); background: color-mix(in oklch, var(--accent), transparent 96%); } +.send-root .dropzone.dragover { border-color: var(--accent); background: color-mix(in oklch, var(--accent), transparent 90%); } +.send-root .dropzone .ic { color: var(--ink-2); margin-bottom: 12px; } +.send-root .dropzone .t { font-size: 16px; color: var(--ink); } +.send-root .dropzone .s { font-size: 13px; color: var(--ink-3); margin-top: 6px; } + +/* File list */ +.send-root .file-list { display: flex; flex-direction: column; gap: 7px; margin-top: 12px; } +.send-root .file-item { + display: flex; align-items: center; gap: 12px; + background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; +} +.send-root .file-item .fic { width: 32px; height: 32px; border-radius: 8px; background: var(--bg-3); border: 1px solid var(--line-2); color: var(--ink-2); display: grid; place-items: center; flex-shrink: 0; } +.send-root .file-item .fbody { flex: 1; min-width: 0; } +.send-root .file-item .fname { font-size: 13.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.send-root .file-item .fsize { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; } +.send-root .file-item .frm { background: transparent; border: none; color: var(--ink-3); cursor: pointer; padding: 4px; border-radius: 6px; display: grid; place-items: center; } +.send-root .file-item .frm:hover { color: var(--danger); background: color-mix(in oklch, var(--danger), transparent 85%); } +.send-root .files-total { font-size: 12px; color: var(--ink-3); margin-top: 10px; font-family: 'JetBrains Mono', monospace; } + +/* Inputs */ +.send-root .inp { + width: 100%; padding: 11px 13px; background: var(--bg-2); border: 1.5px solid var(--line-2); + border-radius: 9px; color: var(--ink); font-size: 14px; outline: none; + transition: border-color .15s, box-shadow .15s; +} +.send-root .inp::placeholder { color: var(--ink-3); } +.send-root .inp:hover { border-color: var(--ink-3); } +.send-root .inp:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 88%); } +.send-root textarea.inp { resize: vertical; min-height: 74px; line-height: 1.5; } +.send-root select.inp { appearance: none; cursor: pointer; padding-right: 36px; } +.send-root .sel-wrap { position: relative; } +.send-root .sel-wrap .chev { position: absolute; right: 13px; top: 50%; transform: translateY(-50%); color: var(--ink-3); pointer-events: none; } + +/* Recipient chips */ +.send-root .chips { + display: flex; flex-wrap: wrap; gap: 7px; align-items: center; + background: var(--bg-2); border: 1.5px solid var(--line-2); border-radius: 9px; padding: 8px 10px; + transition: border-color .15s, box-shadow .15s; +} +.send-root .chips.focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 88%); } +.send-root .chip { + display: inline-flex; align-items: center; gap: 7px; padding: 4px 6px 4px 11px; + background: var(--bg-4); border: 1px solid var(--line-2); border-radius: 999px; + font-size: 13px; color: var(--ink); +} +.send-root .chip.invalid { border-color: color-mix(in oklch, var(--danger), transparent 45%); background: color-mix(in oklch, var(--danger), transparent 84%); color: oklch(0.85 0.10 25); } +.send-root .chip button { background: transparent; border: none; color: currentColor; opacity: 0.6; cursor: pointer; padding: 0; display: grid; place-items: center; } +.send-root .chip button:hover { opacity: 1; } +.send-root .chips input { flex: 1; min-width: 140px; background: transparent; border: none; outline: none; color: var(--ink); font-size: 14px; padding: 4px 2px; } +.send-root .chips input::placeholder { color: var(--ink-3); } + +/* Two-up grid for expiry */ +.send-root .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +/* Verification radio cards */ +.send-root .verify-opts { display: flex; flex-direction: column; gap: 8px; } +.send-root .verify-card { + display: flex; align-items: flex-start; gap: 12px; text-align: left; + background: var(--bg-2); border: 1.5px solid var(--line); border-radius: 11px; padding: 13px 15px; + cursor: pointer; color: var(--ink); font-family: inherit; + transition: border-color .15s, background .15s; +} +.send-root .verify-card:hover { border-color: var(--ink-3); background: var(--bg-3); } +.send-root .verify-card.selected { border-color: var(--accent); background: color-mix(in oklch, var(--accent), transparent 90%); } +.send-root .verify-card .radio { + width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--line-2); flex-shrink: 0; margin-top: 1px; + display: grid; place-items: center; transition: all .15s; +} +.send-root .verify-card.selected .radio { border-color: var(--accent); } +.send-root .verify-card.selected .radio::after { content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); } +.send-root .verify-card .vbody { flex: 1; min-width: 0; } +.send-root .verify-card .vname { font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 8px; } +.send-root .verify-card .vdesc { font-size: 12.5px; color: var(--ink-3); margin-top: 3px; line-height: 1.45; } +.send-root .verify-card .vic { color: var(--ink-2); } +.send-root .verify-card.selected .vic { color: var(--accent); } +.send-root .pw-reveal { margin-top: 10px; } + +/* Toggle */ +.send-root .toggle-row { display: flex; align-items: center; gap: 12px; padding: 12px 2px; } +.send-root .toggle-row .tbody { flex: 1; min-width: 0; } +.send-root .toggle-row .tname { font-size: 14px; color: var(--ink); } +.send-root .toggle-row .tdesc { font-size: 12.5px; color: var(--ink-3); margin-top: 2px; } +.send-root .switch { width: 42px; height: 24px; border-radius: 999px; background: var(--bg-4); border: 1px solid var(--line-2); position: relative; cursor: pointer; flex-shrink: 0; transition: background .18s, border-color .18s; } +.send-root .switch.on { background: var(--accent); border-color: var(--accent); } +.send-root .switch .knob { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .18s; } +.send-root .switch.on .knob { transform: translateX(18px); } + +/* Summary card (split layout) */ +.send-root .summary { + background: var(--bg-2); border: 1px solid var(--line-2); border-radius: 14px; + padding: 18px 18px 16px; position: sticky; top: 24px; +} +.send-root .summary h3 { margin: 0 0 14px; font-size: 14px; font-weight: 600; letter-spacing: -0.1px; } +.send-root .sum-line { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; font-size: 13px; border-bottom: 1px solid var(--line); } +.send-root .sum-line:last-of-type { border-bottom: none; } +.send-root .sum-line .k { color: var(--ink-3); flex-shrink: 0; } +.send-root .sum-line .v { color: var(--ink); text-align: right; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.send-root .sum-line .v.mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; } + +.send-root .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + padding: 11px 18px; border-radius: 9px; font-size: 14px; font-weight: 600; cursor: pointer; + border: 1px solid var(--line-2); background: var(--bg-3); color: var(--ink); + transition: border-color .12s, background .12s, opacity .12s; +} +.send-root .btn:hover { border-color: var(--ink-3); background: var(--bg-4); } +.send-root .btn.sm { padding: 7px 12px; font-size: 12.5px; font-weight: 500; border-radius: 8px; } +.send-root .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +.send-root .btn-primary:hover { background: oklch(0.76 0.10 250); border-color: oklch(0.76 0.10 250); } +.send-root .btn-primary:disabled { opacity: 0.45; cursor: not-allowed; } +.send-root .btn-primary:disabled:hover { background: var(--accent); border-color: var(--accent); } +.send-root .btn-danger { background: color-mix(in oklch, var(--danger), transparent 80%); border-color: color-mix(in oklch, var(--danger), transparent 55%); color: oklch(0.86 0.12 25); } +.send-root .btn-danger:hover { background: color-mix(in oklch, var(--danger), transparent 66%); border-color: var(--danger); } +.send-root .btn-block { width: 100%; } +.send-root .compose-actions { margin-top: 8px; display: none; gap: 10px; } + +/* ============ Sent packages ============ */ +.send-root .pkg-list { display: flex; flex-direction: column; gap: 12px; } +.send-root .pkg-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 13px; padding: 16px 18px; } +.send-root .pkg-top { display: flex; align-items: flex-start; gap: 14px; } +.send-root .pkg-ic { width: 40px; height: 40px; border-radius: 10px; background: var(--bg-3); border: 1px solid var(--line-2); color: var(--ink-2); display: grid; place-items: center; flex-shrink: 0; } +.send-root .pkg-main { flex: 1; min-width: 0; } +.send-root .pkg-name { font-size: 15px; font-weight: 600; color: var(--ink); } +.send-root .pkg-recips { font-size: 12.5px; color: var(--ink-2); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.send-root .pkg-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; font-size: 12px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; } +.send-root .pkg-meta .mk { color: var(--ink-2); } +.send-root .pkg-badges { display: flex; gap: 7px; flex-shrink: 0; align-items: center; } +.send-root .pkg-foot { display: flex; align-items: center; gap: 8px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); } +.send-root .pkg-foot .spacer { flex: 1; } + +.send-root .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; } +.send-root .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.send-root .pill.active { background: color-mix(in oklch, var(--ok), transparent 80%); color: oklch(0.82 0.11 160); border: 1px solid color-mix(in oklch, var(--ok), transparent 60%); } +.send-root .pill.expired { background: var(--bg-3); color: var(--ink-3); border: 1px solid var(--line-2); } +.send-root .pill.revoked { background: color-mix(in oklch, var(--danger), transparent 80%); color: oklch(0.82 0.13 25); border: 1px solid color-mix(in oklch, var(--danger), transparent 58%); } +.send-root .chip-sm { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 500; background: var(--bg-3); border: 1px solid var(--line-2); color: var(--ink-2); white-space: nowrap; } +.send-root .chip-sm.lock { color: oklch(0.80 0.10 250); border-color: color-mix(in oklch, var(--accent), transparent 55%); background: color-mix(in oklch, var(--accent), transparent 88%); } + +.send-root .empty { padding: 40px 18px; border: 1px dashed var(--line-2); border-radius: 12px; text-align: center; color: var(--ink-3); font-size: 13.5px; } + +/* ============ Recipient preview (sender-side tab) ============ */ +.send-root .preview-bar { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + background: var(--bg-2); border: 1px solid var(--line-2); border-radius: 11px; + padding: 12px 16px; margin-bottom: 22px; +} +.send-root .preview-bar .pl { font-size: 12.5px; color: var(--ink-3); display: inline-flex; align-items: center; gap: 8px; } +.send-root .preview-bar .spacer { flex: 1; } + +/* ============ Public recipient screens (shared with the real /s/:id page) ============ */ +.send-root .public-stage { background: var(--bg); border: 1px solid var(--line); border-radius: 16px; padding: 46px 24px 54px; display: grid; place-items: center; } +.send-root .public-card { + width: 100%; max-width: 460px; background: var(--bg-2); border: 1px solid var(--line-2); + border-radius: 16px; padding: 30px 30px 28px; box-shadow: 0 24px 70px rgba(0,0,0,0.45); +} +.send-root .public-brand { display: inline-flex; align-items: center; gap: 10px; margin-bottom: 22px; } +.send-root .public-brand .mark { width: 26px; height: 26px; color: var(--ink); } +.send-root .public-brand .name { font-size: 16px; font-weight: 600; } +.send-root .public-from { font-size: 13.5px; color: var(--ink-2); } +.send-root .public-from strong { color: var(--ink); font-weight: 600; } +.send-root .public-pkgname { font-size: 23px; font-weight: 600; letter-spacing: -0.3px; margin: 6px 0 0; } +.send-root .public-msg { margin-top: 16px; padding: 13px 15px; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; font-size: 13.5px; color: var(--ink-2); line-height: 1.55; } +.send-root .public-files { margin-top: 20px; display: flex; flex-direction: column; gap: 7px; } +.send-root .public-file { display: flex; align-items: center; gap: 11px; padding: 9px 4px; border-bottom: 1px solid var(--line); } +.send-root .public-file:last-child { border-bottom: none; } +.send-root .public-file .fic { color: var(--ink-3); flex-shrink: 0; } +.send-root .public-file .fn { flex: 1; min-width: 0; font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.send-root .public-file .fs { font-size: 12px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; flex-shrink: 0; } +.send-root .public-file .file-dl { + flex-shrink: 0; width: 30px; height: 30px; border-radius: 8px; + background: var(--bg-3); border: 1px solid var(--line-2); color: var(--ink-2); + display: grid; place-items: center; cursor: pointer; + transition: color .12s, border-color .12s, background .12s; +} +.send-root .public-file .file-dl:hover { color: var(--accent); border-color: color-mix(in oklch, var(--accent), transparent 45%); background: color-mix(in oklch, var(--accent), transparent 88%); } +.send-root .public-file .file-dl.inert { cursor: default; opacity: 0.55; } +.send-root .public-file .file-dl.inert:hover { color: var(--ink-2); border-color: var(--line-2); background: var(--bg-3); } +.send-root .public-total { display: flex; justify-content: space-between; font-size: 12.5px; color: var(--ink-3); margin-top: 14px; padding-top: 4px; } +.send-root .public-dl { margin-top: 22px; } +.send-root .public-note { text-align: center; font-size: 12px; color: var(--ink-3); margin-top: 14px; line-height: 1.5; } + +/* Verify screen */ +.send-root .verify-icon { width: 52px; height: 52px; border-radius: 13px; background: color-mix(in oklch, var(--accent), transparent 85%); border: 1px solid color-mix(in oklch, var(--accent), transparent 55%); color: var(--accent); display: grid; place-items: center; margin-bottom: 20px; } +.send-root .verify-h { font-size: 21px; font-weight: 600; letter-spacing: -0.3px; margin: 0; } +.send-root .verify-p { font-size: 13.5px; color: var(--ink-2); margin: 10px 0 24px; line-height: 1.55; } +.send-root .verify-p b { color: var(--ink); } +.send-root .verify-error { font-size: 13px; color: oklch(0.85 0.10 25); background: color-mix(in oklch, var(--danger), transparent 85%); border: 1px solid color-mix(in oklch, var(--danger), transparent 60%); border-radius: 8px; padding: 9px 12px; margin-top: 12px; } +.send-root .pw-verify { margin-top: 4px; } + +/* Terminal states (expired / revoked / not found) on the real recipient page */ +.send-root .terminal-icon { width: 52px; height: 52px; border-radius: 13px; background: var(--bg-3); border: 1px solid var(--line-2); color: var(--ink-3); display: grid; place-items: center; margin-bottom: 20px; } + +.send-root .toast { + position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); + background: var(--bg-4); border: 1px solid var(--line-2); color: var(--ink); + padding: 11px 18px; border-radius: 10px; font-size: 13.5px; z-index: 50; + box-shadow: 0 14px 40px rgba(0,0,0,0.5); display: flex; align-items: center; gap: 9px; +} +.send-root .toast .ok { color: var(--ok); } diff --git a/frontend/src/components/send/PackageComposeForm.vue b/frontend/src/components/send/PackageComposeForm.vue new file mode 100644 index 0000000..04f510d --- /dev/null +++ b/frontend/src/components/send/PackageComposeForm.vue @@ -0,0 +1,236 @@ + + + diff --git a/frontend/src/components/send/PackageDownloadScreen.vue b/frontend/src/components/send/PackageDownloadScreen.vue new file mode 100644 index 0000000..7d4ce06 --- /dev/null +++ b/frontend/src/components/send/PackageDownloadScreen.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/components/send/PackageFileRow.vue b/frontend/src/components/send/PackageFileRow.vue new file mode 100644 index 0000000..a77d53e --- /dev/null +++ b/frontend/src/components/send/PackageFileRow.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/send/PackageVerifyScreen.vue b/frontend/src/components/send/PackageVerifyScreen.vue new file mode 100644 index 0000000..9cd14b2 --- /dev/null +++ b/frontend/src/components/send/PackageVerifyScreen.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/send/RecipientChipInput.vue b/frontend/src/components/send/RecipientChipInput.vue new file mode 100644 index 0000000..8e9c089 --- /dev/null +++ b/frontend/src/components/send/RecipientChipInput.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/components/send/RecipientPreviewTab.vue b/frontend/src/components/send/RecipientPreviewTab.vue new file mode 100644 index 0000000..f806b69 --- /dev/null +++ b/frontend/src/components/send/RecipientPreviewTab.vue @@ -0,0 +1,85 @@ + + + diff --git a/frontend/src/components/send/SendUserMenu.vue b/frontend/src/components/send/SendUserMenu.vue new file mode 100644 index 0000000..43882df --- /dev/null +++ b/frontend/src/components/send/SendUserMenu.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/components/send/SentPackageCard.vue b/frontend/src/components/send/SentPackageCard.vue new file mode 100644 index 0000000..efcf7f6 --- /dev/null +++ b/frontend/src/components/send/SentPackageCard.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/components/send/SentPackagesList.vue b/frontend/src/components/send/SentPackagesList.vue new file mode 100644 index 0000000..9c8ac5c --- /dev/null +++ b/frontend/src/components/send/SentPackagesList.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/components/send/VerificationMethodPicker.vue b/frontend/src/components/send/VerificationMethodPicker.vue new file mode 100644 index 0000000..28f6086 --- /dev/null +++ b/frontend/src/components/send/VerificationMethodPicker.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/composables/usePackagePreview.ts b/frontend/src/composables/usePackagePreview.ts new file mode 100644 index 0000000..77b588a --- /dev/null +++ b/frontend/src/composables/usePackagePreview.ts @@ -0,0 +1,58 @@ +import { ref } from 'vue' +import type { PackagePreview } from './usePackages' + +// Per-call state (like useTusUpload, unlike the shared usePackages singleton) +// — each viewer of a package's preview (the real public page, and the +// sender's own "Recipient preview" tab) gets its own independent state. +export function usePackagePreview() { + const preview = ref(null) + const loading = ref(false) + const error = ref(null) + const verifying = ref(false) + const verifyError = ref(null) + + async function load(packageId: string) { + loading.value = true + error.value = null + try { + const res = await fetch(`/api/packages/${encodeURIComponent(packageId)}/preview`, { + credentials: 'same-origin', + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + error.value = body?.error || `Request failed (${res.status})` + preview.value = null + return + } + preview.value = await res.json() + } catch { + error.value = 'Failed to load package' + preview.value = null + } finally { + loading.value = false + } + } + + async function verifyPassword(packageId: string, password: string) { + verifying.value = true + verifyError.value = null + try { + const res = await fetch(`/api/packages/${encodeURIComponent(packageId)}/verify`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + verifyError.value = body?.error || `Request failed (${res.status})` + return + } + await load(packageId) + } finally { + verifying.value = false + } + } + + return { preview, loading, error, verifying, verifyError, load, verifyPassword } +} diff --git a/frontend/src/composables/usePackages.ts b/frontend/src/composables/usePackages.ts new file mode 100644 index 0000000..b46e0bf --- /dev/null +++ b/frontend/src/composables/usePackages.ts @@ -0,0 +1,113 @@ +import { ref } from 'vue' + +export type VerificationMethod = 'none' | 'password' | 'bcc_login' | 'email_otp' | 'magic_link' + +export interface PackageInfo { + packageId: string + name: string + message: string + verificationMethod: VerificationMethod + recipients: string[] + fileCount: number + totalSize: number + downloadCount: number + maxDownloads: number | null + expiresAt: string + isExpired: boolean + isDownloadLimitHit: boolean + status: string + createdAt: string +} + +export interface CreatePackageInput { + name: string + message?: string + uploadIds: string[] + recipients?: string[] + expiresInDays: number + maxDownloads?: number + verificationMethod?: VerificationMethod + password?: string + notifyOnDownload?: boolean +} + +export interface CreatePackageResult { + packageId: string + packageUrl: string + expiresAt: string +} + +export interface PackageFile { + shareId: string + filename: string + size: number +} + +export interface PackagePreview { + name: string + message: string + verificationMethod: VerificationMethod + expiresAt: string + maxDownloads: number | null + downloadCount: number + verified: boolean + files?: PackageFile[] +} + +async function jsonFetch(input: string, init?: RequestInit): Promise { + const res = await fetch(input, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + ...init, + }) + if (!res.ok) { + let msg = `Request failed (${res.status})` + try { + const body = await res.json() + if (body?.error) msg = body.error + } catch { + /* ignore */ + } + throw new Error(msg) + } + if (res.status === 204) return undefined as T + return res.json() as Promise +} + +const packages = ref([]) +const total = ref(0) +const loading = ref(false) +const lastError = ref(null) + +async function fetchPackages(page = 1, pageSize = 20) { + loading.value = true + lastError.value = null + try { + const data = await jsonFetch<{ packages: PackageInfo[]; page: number; pageSize: number; total: number }>( + `/api/packages?page=${page}&pageSize=${pageSize}`, + ) + packages.value = data.packages + total.value = data.total + } catch (e) { + lastError.value = (e as Error).message + } finally { + loading.value = false + } +} + +async function createPackage(input: CreatePackageInput): Promise { + return jsonFetch('/api/packages', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +async function revokePackage(packageId: string) { + await jsonFetch(`/api/packages/${encodeURIComponent(packageId)}`, { method: 'DELETE' }) + const pkg = packages.value.find((p) => p.packageId === packageId) + if (pkg) pkg.status = 'revoked' +} + +export function usePackages() { + return { packages, total, loading, lastError, fetchPackages, createPackage, revokePackage } +} diff --git a/frontend/src/composables/useTusUpload.ts b/frontend/src/composables/useTusUpload.ts index 4ca7fd8..7956b12 100644 --- a/frontend/src/composables/useTusUpload.ts +++ b/frontend/src/composables/useTusUpload.ts @@ -75,6 +75,7 @@ export function useTusUpload() { bytesTotal: file.size, speed: 0, error: reason, + uploadId: null, }) uploads.value.push(item) if (!reason) startUpload(item, target, formData) @@ -116,6 +117,10 @@ export function useTusUpload() { item.status = 'completed' item.progress = 100 item.speed = 0 + // The tus resource URL's last path segment is the server-assigned + // upload ID (same value tusd hands to the backend as info.ID). + const segments = upload.url?.split('/').filter(Boolean) ?? [] + item.uploadId = segments.length ? segments[segments.length - 1] : null }, onError(error: Error) { item.status = 'failed' diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 2b41585..c2452da 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -2,11 +2,23 @@ import { createRouter, createWebHistory } from 'vue-router' const Home = () => import('./views/Home.vue') const Admin = () => import('./views/Admin.vue') +const Send = () => import('./views/Send.vue') +const RecipientPage = () => import('./views/RecipientPage.vue') + +declare module 'vue-router' { + interface RouteMeta { + // Routes exempt from the LoginGate check in App.vue — for pages an + // external recipient with no BCC session must be able to reach. + public?: boolean + } +} export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'home', component: Home }, { path: '/admin', name: 'admin', component: Admin }, + { path: '/send', name: 'send', component: Send }, + { path: '/s/:packageId', name: 'recipient', component: RecipientPage, meta: { public: true } }, ], }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b262c0e..6762f4e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -14,6 +14,9 @@ export interface UploadItem { bytesTotal: number speed: number error: string | null + // Server-assigned upload ID, set once the tus upload completes. Needed to + // reference this file elsewhere (e.g. bundling it into a Send package). + uploadId: string | null } export interface UploadRecord { diff --git a/frontend/src/views/RecipientPage.vue b/frontend/src/views/RecipientPage.vue new file mode 100644 index 0000000..4e5ff7c --- /dev/null +++ b/frontend/src/views/RecipientPage.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/views/Send.vue b/frontend/src/views/Send.vue new file mode 100644 index 0000000..5eb94c1 --- /dev/null +++ b/frontend/src/views/Send.vue @@ -0,0 +1,63 @@ + + + From 18f2cc959447b45fcc9f5874406f8f0d8154bfbb Mon Sep 17 00:00:00 2001 From: JWSametz Date: Thu, 13 Aug 2026 15:02:48 +0200 Subject: [PATCH 10/38] fix(frontend): add a catch to verifyPassword that sets verifyError on network failure --- frontend/src/composables/usePackagePreview.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/composables/usePackagePreview.ts b/frontend/src/composables/usePackagePreview.ts index 77b588a..9d2a9d6 100644 --- a/frontend/src/composables/usePackagePreview.ts +++ b/frontend/src/composables/usePackagePreview.ts @@ -49,6 +49,8 @@ export function usePackagePreview() { return } await load(packageId) + } catch { + verifyError.value = 'Failed to verify. Check your connection and try again.' } finally { verifying.value = false } From 358db3a118a352530dcd475dac7360ab7e6b5bde Mon Sep 17 00:00:00 2001 From: JWSametz Date: Thu, 13 Aug 2026 15:14:58 +0200 Subject: [PATCH 11/38] fix(frontend): fix typos and header for usePackages.ts --- frontend/src/composables/usePackages.ts | 2 +- internal/api/package_delivery.go | 26 ++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/composables/usePackages.ts b/frontend/src/composables/usePackages.ts index b46e0bf..cd71000 100644 --- a/frontend/src/composables/usePackages.ts +++ b/frontend/src/composables/usePackages.ts @@ -57,8 +57,8 @@ export interface PackagePreview { async function jsonFetch(input: string, init?: RequestInit): Promise { const res = await fetch(input, { credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, ...init, + headers: { 'Content-Type': 'application/json', ...init?.headers }, }) if (!res.ok) { let msg = `Request failed (${res.status})` diff --git a/internal/api/package_delivery.go b/internal/api/package_delivery.go index aebda48..9eef86c 100644 --- a/internal/api/package_delivery.go +++ b/internal/api/package_delivery.go @@ -66,17 +66,17 @@ func (h *Handlers) GetPackagePreview(w http.ResponseWriter, r *http.Request) { pkg, err := h.queries.GetPackageByID(r.Context(), packageID) if err != nil { - writeJSONError(w, http.StatusNotFound, "package not found") + writeJSONError(w, http.StatusNotFound, "Package not found") return } if pkg.Status != "active" { - writeJSONError(w, http.StatusGone, "package has been revoked") + writeJSONError(w, http.StatusGone, "Package has been revoked") return } if !pkg.ExpiresAt.After(time.Now()) { - writeJSONError(w, http.StatusGone, "package has expired") + writeJSONError(w, http.StatusGone, "Package has expired") return } @@ -103,7 +103,7 @@ func (h *Handlers) GetPackagePreview(w http.ResponseWriter, r *http.Request) { if resp.Verified { rows, err := h.queries.ListSharesByPackageID(r.Context(), pkg.ID) if err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to list package files") + writeJSONError(w, http.StatusInternalServerError, "Failed to list package files") return } files := make([]packageFileView, len(rows)) @@ -135,44 +135,44 @@ func (h *Handlers) VerifyPackage(w http.ResponseWriter, r *http.Request) { pkg, err := h.queries.GetPackageByID(r.Context(), packageID) if err != nil { - writeJSONError(w, http.StatusNotFound, "package not found") + writeJSONError(w, http.StatusNotFound, "Package not found") return } if pkg.Status != "active" { - writeJSONError(w, http.StatusGone, "package has been revoked") + writeJSONError(w, http.StatusGone, "Package has been revoked") return } if !pkg.ExpiresAt.After(time.Now()) { - writeJSONError(w, http.StatusGone, "package has expired") + writeJSONError(w, http.StatusGone, "Package has expired") return } if pkg.VerificationMethod != "password" { - writeJSONError(w, http.StatusBadRequest, "package does not use password verification") + writeJSONError(w, http.StatusBadRequest, "Package does not use password verification") return } var req verifyPackageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid request") + writeJSONError(w, http.StatusBadRequest, "Invalid request") return } if req.Password == "" { - writeJSONError(w, http.StatusBadRequest, "password is required") + writeJSONError(w, http.StatusBadRequest, "Password is required") return } if err := bcrypt.CompareHashAndPassword([]byte(pkg.PasswordHash.String), []byte(req.Password)); err != nil { - writeJSONError(w, http.StatusForbidden, "incorrect password") + writeJSONError(w, http.StatusForbidden, "Incorrect password") return } token, err := generateShareID() if err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to generate verification token") + writeJSONError(w, http.StatusInternalServerError, "Failed to generate verification token") return } @@ -181,7 +181,7 @@ func (h *Handlers) VerifyPackage(w http.ResponseWriter, r *http.Request) { PackageID: pkg.ID, ExpiresAt: pkg.ExpiresAt, }); err != nil { - writeJSONError(w, http.StatusInternalServerError, "failed to record verification") + writeJSONError(w, http.StatusInternalServerError, "Failed to record verification") return } From 1749c6f1478550fbe4d9ffb64c61880c81264867 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Thu, 13 Aug 2026 16:47:57 +0200 Subject: [PATCH 12/38] fix(frontend): stop deleting completed uploads from tus server on send --- .../components/send/PackageComposeForm.vue | 33 ++++++++----------- frontend/src/composables/useTusUpload.ts | 19 +++++++++-- internal/server/server.go | 24 ++++++++++++++ 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/send/PackageComposeForm.vue b/frontend/src/components/send/PackageComposeForm.vue index 04f510d..8b4890b 100644 --- a/frontend/src/components/send/PackageComposeForm.vue +++ b/frontend/src/components/send/PackageComposeForm.vue @@ -1,35 +1,25 @@ From fd89bbab6219f4267e2bd298cc936afad06ba028 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Fri, 14 Aug 2026 09:55:29 +0200 Subject: [PATCH 15/38] fix(frontend): fixed time inconsistency and synchronization bugs --- frontend/src/components/send/SentPackagesList.vue | 10 ++++------ frontend/src/views/Send.vue | 5 ++--- internal/api/package_delivery.go | 2 +- internal/api/packages.go | 4 ++-- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/send/SentPackagesList.vue b/frontend/src/components/send/SentPackagesList.vue index 27514a1..11e5614 100644 --- a/frontend/src/components/send/SentPackagesList.vue +++ b/frontend/src/components/send/SentPackagesList.vue @@ -5,7 +5,7 @@ import SentPackageCard from './SentPackageCard.vue' const emit = defineEmits<{ preview: [packageId: string] }>() -const { packages, loading, fetchPackages, revokePackage } = usePackages() +const { packages, loading, revokePackage } = usePackages() const toast = ref('') let toastTimer: number | null = null @@ -31,11 +31,9 @@ async function revoke(packageId: string, name: string) { } } -// No fetch on mount here — usePackages() is a shared singleton, and Send.vue -// (this component's only parent) already fetches on its own mount, before -// this tab can even be selected. `loading`/`packages` below are the same -// shared refs, so the "Loading…" state still renders correctly either way. -defineExpose({ refresh: fetchPackages }) +// No fetch on mount here — usePackages() is a shared singleton. Send.vue +// fetches on its own mount and again after a package is sent, so `packages`/ +// `loading` below are already current by the time this tab can be selected. From 3328bdc36872e964784e4d9821e7b3a0250b8008 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Fri, 14 Aug 2026 11:18:51 +0200 Subject: [PATCH 18/38] fix(frontend): qol fixes --- .../components/send/RecipientPreviewTab.vue | 13 +++++++--- frontend/src/composables/useAuth.ts | 3 ++- frontend/src/composables/useTusUpload.ts | 6 ++++- frontend/src/views/RecipientPage.vue | 3 ++- internal/auth/handlers.go | 26 ++++++++++++++++++- 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/send/RecipientPreviewTab.vue b/frontend/src/components/send/RecipientPreviewTab.vue index f806b69..ee06227 100644 --- a/frontend/src/components/send/RecipientPreviewTab.vue +++ b/frontend/src/components/send/RecipientPreviewTab.vue @@ -24,12 +24,17 @@ watch( }, ) -watch(selected, (id) => { - if (id) load(id) -}) +watch( + selected, + (id) => { + if (id) load(id) + }, + { immediate: true }, +) function signInBcc() { - window.location.href = '/auth/login/bcc' + const returnTo = encodeURIComponent(window.location.pathname + window.location.search) + window.location.href = `/auth/login/bcc?returnTo=${returnTo}` } diff --git a/frontend/src/composables/useAuth.ts b/frontend/src/composables/useAuth.ts index f514bc7..7d10787 100644 --- a/frontend/src/composables/useAuth.ts +++ b/frontend/src/composables/useAuth.ts @@ -46,7 +46,8 @@ export function initAuth(): Promise { } function signIn(providerId: string) { - window.location.href = `/auth/login/${encodeURIComponent(providerId)}` + const returnTo = encodeURIComponent(window.location.pathname + window.location.search) + window.location.href = `/auth/login/${encodeURIComponent(providerId)}?returnTo=${returnTo}` } async function continueAsGuest(name: string, email: string): Promise { diff --git a/frontend/src/composables/useTusUpload.ts b/frontend/src/composables/useTusUpload.ts index acebc01..ab680a0 100644 --- a/frontend/src/composables/useTusUpload.ts +++ b/frontend/src/composables/useTusUpload.ts @@ -119,7 +119,11 @@ export function useTusUpload() { item.speed = 0 // The tus resource URL's last path segment is the server-assigned // upload ID (same value tusd hands to the backend as info.ID). - const segments = upload.url?.split('/').filter(Boolean) ?? [] + // Parsed via URL/pathname (not a raw string split) so a query + // string or hash on upload.url can never leak into the ID. + const segments = upload.url + ? new URL(upload.url, location.origin).pathname.split('/').filter(Boolean) + : [] item.uploadId = segments.length ? segments[segments.length - 1] : null }, onError(error: Error) { diff --git a/frontend/src/views/RecipientPage.vue b/frontend/src/views/RecipientPage.vue index 1ed145f..e6dc736 100644 --- a/frontend/src/views/RecipientPage.vue +++ b/frontend/src/views/RecipientPage.vue @@ -15,7 +15,8 @@ const { preview, loading, error, verifying, verifyError, load, verifyPassword, r onMounted(() => load(packageId)) function signInBcc() { - window.location.href = '/auth/login/bcc' + const returnTo = encodeURIComponent(window.location.pathname + window.location.search) + window.location.href = `/auth/login/bcc?returnTo=${returnTo}` } diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index c3be133..fec8da8 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "net/mail" + "net/url" "strings" db "filebox/internal/db/gen" @@ -121,6 +122,24 @@ type oauthStateCookie struct { Nonce string `json:"n"` Verifier string `json:"v"` Redirect string `json:"r"` + ReturnTo string `json:"rt"` +} + +// sanitizeReturnTo restricts a caller-supplied return-to path to a same-site +// relative path, so a crafted /auth/login/...?returnTo= link can never turn +// a real login into an open redirect (e.g. returnTo=https://evil.example, +// returnTo=//evil.example, or the //-via-backslash browser quirk). +func sanitizeReturnTo(raw string) string { + if raw == "" || raw[0] != '/' { + return "/" + } + if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') { + return "/" + } + if u, err := url.Parse(raw); err != nil || u.Scheme != "" || u.Host != "" { + return "/" + } + return raw } func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { @@ -152,6 +171,7 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { } challenge := pkceChallenge(verifier) redirectURL := p.RedirectURL(h.baseURL, r) + returnTo := sanitizeReturnTo(r.URL.Query().Get("returnTo")) raw, err := json.Marshal(oauthStateCookie{ Provider: providerID, @@ -159,6 +179,7 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { Nonce: nonce, Verifier: verifier, Redirect: redirectURL, + ReturnTo: returnTo, }) if err != nil { http.Error(w, "", http.StatusInternalServerError) @@ -283,7 +304,10 @@ func (h *Handlers) Callback(w http.ResponseWriter, r *http.Request) { return } - http.Redirect(w, r, "/", http.StatusFound) + // Re-validated rather than trusted outright: a state cookie issued by a + // previous deploy (before this field existed) would decode with an empty + // ReturnTo, which sanitizeReturnTo turns back into a safe default. + http.Redirect(w, r, sanitizeReturnTo(sc.ReturnTo), http.StatusFound) } func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { From 67a1feefd3dc6d7839079e749e39bfb514271060 Mon Sep 17 00:00:00 2001 From: JWSametz Date: Fri, 14 Aug 2026 11:36:54 +0200 Subject: [PATCH 19/38] feat(frontend): add sender name --- frontend/src/components/send/PackageDownloadScreen.vue | 10 +++++++++- frontend/src/components/send/RecipientPreviewTab.vue | 1 + frontend/src/composables/usePackages.ts | 1 + frontend/src/views/RecipientPage.vue | 1 + internal/api/package_delivery.go | 8 ++++++++ 5 files changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/send/PackageDownloadScreen.vue b/frontend/src/components/send/PackageDownloadScreen.vue index 0b0c5f6..b06f7b3 100644 --- a/frontend/src/components/send/PackageDownloadScreen.vue +++ b/frontend/src/components/send/PackageDownloadScreen.vue @@ -4,6 +4,7 @@ import type { PackageFile } from '../../composables/usePackages' const props = defineProps<{ packageName: string + senderName: string message: string files: PackageFile[] expiresAt: string @@ -60,7 +61,14 @@ function downloadAll() {