Add /admin/uploads HTML page and JSON API backed by the upload_files DB index. The page lists file_id, name, size (KB), uploaded_at, and sha256 with client-side search by name, column sort, and per-row delete. DELETE /admin/uploads/:id removes the DB index row and the on-disk data file plus .meta.json sidecar. Admin deletes are written to the audit log. Wire the new dependencies through admin.Mount and main.go, and run the backfill step on startup so pre-existing sidecars are indexed. Tests cover auth, HTML rendering, JSON search, delete removing all artifacts, and audit entry creation. Co-Authored-By: Claude <noreply@anthropic.com>
148 lines
4.0 KiB
Go
148 lines
4.0 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// UploadMeta is the database index record for an uploaded file.
|
|
//
|
|
// The .meta.json sidecar remains the source of truth; this struct is the
|
|
// queryable mirror used by the admin UI.
|
|
type UploadMeta struct {
|
|
FileID string `json:"file_id"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
UploadedAt time.Time `json:"uploaded_at"`
|
|
}
|
|
|
|
// UploadRepo provides CRUD operations for upload file index records.
|
|
type UploadRepo struct {
|
|
db *DB
|
|
}
|
|
|
|
// Uploads returns a repository bound to this DB handle.
|
|
func (d *DB) Uploads() *UploadRepo {
|
|
return &UploadRepo{db: d}
|
|
}
|
|
|
|
// Create inserts a new upload index record. Timestamps are stored as Unix
|
|
// nanoseconds for stable ORDER BY semantics.
|
|
func (r *UploadRepo) Create(ctx context.Context, fileID, name string, size int64, sha256Hex string, uploadedAt time.Time) error {
|
|
_, err := r.db.sqlDB.ExecContext(ctx, `
|
|
INSERT INTO upload_files (file_id, name, size, sha256, uploaded_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
fileID, name, size, sha256Hex, uploadedAt.UnixNano(),
|
|
)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
return fmt.Errorf("storage: create upload %s: primary key conflict: %w", fileID, err)
|
|
}
|
|
return fmt.Errorf("storage: create upload %s: %w", fileID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves an upload index record by file_id. If the record does not
|
|
// exist, it returns ErrNotFound.
|
|
func (r *UploadRepo) Get(ctx context.Context, fileID string) (UploadMeta, error) {
|
|
row := r.db.sqlDB.QueryRowContext(ctx, `
|
|
SELECT file_id, name, size, sha256, uploaded_at
|
|
FROM upload_files
|
|
WHERE file_id = ?`, fileID)
|
|
|
|
m, err := scanUpload(row.Scan)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return UploadMeta{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return UploadMeta{}, fmt.Errorf("storage: get upload %s: %w", fileID, err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// List returns upload index records ordered by uploaded_at descending.
|
|
//
|
|
// If search is non-empty, name is filtered with a case-insensitive LIKE.
|
|
// A limit of zero or less falls back to 100; values above 500 are capped.
|
|
func (r *UploadRepo) List(ctx context.Context, search string, limit int) ([]UploadMeta, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
|
|
var rows *sql.Rows
|
|
var err error
|
|
if search != "" {
|
|
rows, err = r.db.sqlDB.QueryContext(ctx, `
|
|
SELECT file_id, name, size, sha256, uploaded_at
|
|
FROM upload_files
|
|
WHERE name LIKE ?
|
|
ORDER BY uploaded_at DESC
|
|
LIMIT ?`,
|
|
"%"+search+"%", limit,
|
|
)
|
|
} else {
|
|
rows, err = r.db.sqlDB.QueryContext(ctx, `
|
|
SELECT file_id, name, size, sha256, uploaded_at
|
|
FROM upload_files
|
|
ORDER BY uploaded_at DESC
|
|
LIMIT ?`, limit)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list uploads: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []UploadMeta
|
|
for rows.Next() {
|
|
m, err := scanUpload(rows.Scan)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list uploads: %w", err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("storage: list uploads: %w", err)
|
|
}
|
|
if out == nil {
|
|
// Force empty slice (not null) in JSON.
|
|
out = []UploadMeta{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Delete removes an upload index record by file_id. If the record does not
|
|
// exist, it returns ErrNotFound.
|
|
func (r *UploadRepo) Delete(ctx context.Context, fileID string) error {
|
|
res, err := r.db.sqlDB.ExecContext(ctx, `DELETE FROM upload_files WHERE file_id = ?`, fileID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete upload %s: %w", fileID, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete upload %s: rows affected: %w", fileID, err)
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanUpload(scan scanFunc) (UploadMeta, error) {
|
|
var m UploadMeta
|
|
var uploadedAt int64
|
|
if err := scan(&m.FileID, &m.Name, &m.Size, &m.SHA256, &uploadedAt); err != nil {
|
|
return UploadMeta{}, err
|
|
}
|
|
m.UploadedAt = time.Unix(0, uploadedAt)
|
|
return m, nil
|
|
}
|