admin: submissions page, download endpoint, uploads search by app_id

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-14 13:45:11 +08:00
co-authored by Claude
parent 573397dbb4
commit 25f93dfb59
10 changed files with 683 additions and 26 deletions
+80 -1
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -22,7 +23,7 @@ import (
// Mount attaches the /admin sub-router to r, protecting every route with
// bearer-token admin authentication.
func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadRepo, auditRepo *audit.Repo, uploadStore *uploads.Store, adminTokens []string) {
func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadRepo, submissionRepo *storage.SubmissionRepo, 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")
@@ -38,7 +39,10 @@ func Mount(r *gin.Engine, repo *storage.ClusterRepo, uploadRepo *storage.UploadR
g.GET("/audit", listAudit(auditRepo))
g.GET("/uploads", uploadsWebHandler)
g.GET("/uploads/api", listUploads(uploadRepo))
g.GET("/uploads/:id/download", downloadUpload(uploadRepo, uploadStore))
g.DELETE("/uploads/:id", deleteUpload(uploadRepo, auditRepo, uploadStore))
g.GET("/submissions", submissionsWebHandler)
g.GET("/submissions/api", listSubmissions(submissionRepo))
g.GET("/docs", DocsHandler)
g.GET("/docs/spec", OpenAPISpecHandler)
}
@@ -300,6 +304,81 @@ func listUploads(repo *storage.UploadRepo) gin.HandlerFunc {
}
}
func listSubmissions(repo *storage.SubmissionRepo) gin.HandlerFunc {
return func(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
limit, err := strconv.Atoi(limitStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
return
}
offsetStr := c.DefaultQuery("offset", "0")
offset, err := strconv.Atoi(offsetStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
return
}
subs, err := repo.List(c.Request.Context(), storage.SubmissionFilter{
Search: c.Query("search"),
FileID: c.Query("file_id"),
ClusterID: c.Query("cluster_id"),
AppID: c.Query("app_id"),
Limit: limit,
Offset: offset,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, subs)
}
}
func downloadUpload(repo *storage.UploadRepo, 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 {
if errors.Is(err, storage.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "upload not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
dataPath, err := store.AbsPath(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
data, err := os.ReadFile(dataPath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "upload file missing"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", "attachment; filename=\""+escapeDispositionFilename(meta.Name)+"\"")
c.Data(http.StatusOK, "application/octet-stream", data)
}
}
// escapeDispositionFilename escapes quotes and backslashes for the
// Content-Disposition filename parameter. The upload validator already rejects
// names containing quotes or newlines; this is defense in depth.
func escapeDispositionFilename(name string) string {
name = strings.ReplaceAll(name, "\\", "\\\\")
name = strings.ReplaceAll(name, "\"", "\\\"")
return name
}
func deleteUpload(repo *storage.UploadRepo, auditRepo *audit.Repo, store *uploads.Store) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")