Files
spark-mcp/internal/storage/upload_repo.go
T
tao.chenandClaude df9b01272f admin: uploads list view with search, sort, and delete
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>
2026-07-14 11:39:56 +08:00

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
}