admin: submissions page, download endpoint, uploads search by app_id
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user