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>
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,11 +17,12 @@ import (
|
||||
"spark-mcp-go/internal/cluster"
|
||||
"spark-mcp-go/internal/middleware"
|
||||
"spark-mcp-go/internal/storage"
|
||||
"spark-mcp-go/internal/uploads"
|
||||
)
|
||||
|
||||
// Mount attaches the /admin sub-router to r, protecting every route with
|
||||
// bearer-token admin authentication.
|
||||
func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, adminTokens []string) {
|
||||
func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadRepo, auditRepo *audit.Repo, uploadStore *uploads.Store, adminTokens []string) {
|
||||
// HTML page is public — its own modal prompts for the token.
|
||||
// All other /admin/* endpoints (API + OpenAPI docs) still require auth.
|
||||
gPublic := r.Group("/admin")
|
||||
@@ -33,6 +36,9 @@ func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, admi
|
||||
g.PUT("/clusters/:id", updateCluster(repo, auditRepo))
|
||||
g.DELETE("/clusters/:id", deleteCluster(repo, auditRepo))
|
||||
g.GET("/audit", listAudit(auditRepo))
|
||||
g.GET("/uploads", uploadsWebHandler)
|
||||
g.GET("/uploads/api", listUploads(uploadRepo))
|
||||
g.DELETE("/uploads/:id", deleteUpload(uploadRepo, auditRepo, uploadStore))
|
||||
g.GET("/docs", DocsHandler)
|
||||
g.GET("/docs/spec", OpenAPISpecHandler)
|
||||
}
|
||||
@@ -275,6 +281,66 @@ func deleteCluster(repo *storage.ClusterRepo, auditRepo *audit.Repo) gin.Handler
|
||||
}
|
||||
}
|
||||
|
||||
func listUploads(repo *storage.UploadRepo) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
|
||||
return
|
||||
}
|
||||
|
||||
uploads, err := repo.List(c.Request.Context(), search, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteUpload(repo *storage.UploadRepo, auditRepo *audit.Repo, store *uploads.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
meta, err := repo.Get(ctx, id)
|
||||
if err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, id); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Always attempt to remove the on-disk files; the DB is only an index.
|
||||
if store != nil {
|
||||
dataPath := filepath.Join(store.Root, id)
|
||||
_ = os.Remove(dataPath)
|
||||
_ = os.Remove(dataPath + ".meta.json")
|
||||
}
|
||||
|
||||
if meta.FileID != "" {
|
||||
details, _ := audit.MarshalDetails(map[string]any{
|
||||
"file_id": id,
|
||||
"name": meta.Name,
|
||||
"size": meta.Size,
|
||||
"sha256": meta.SHA256,
|
||||
})
|
||||
_ = auditRepo.Insert(ctx, &audit.Entry{
|
||||
Actor: actor(c),
|
||||
Action: audit.ActionUploadDelete,
|
||||
ClusterID: id,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
func listAudit(auditRepo *audit.Repo) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
|
||||
Reference in New Issue
Block a user