add app review system with validation pipeline and admin htmx UI
This commit is contained in:
226
portal/internal/api/handlers/admin.go
Normal file
226
portal/internal/api/handlers/admin.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// Package handlers contains HTTP request handlers
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/omixlab/mosis-portal/internal/database"
|
||||||
|
"github.com/omixlab/mosis-portal/internal/review"
|
||||||
|
"github.com/omixlab/mosis-portal/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminHandler handles admin operations
|
||||||
|
type AdminHandler struct {
|
||||||
|
db *database.DB
|
||||||
|
store *storage.Storage
|
||||||
|
review *review.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminHandler creates a new admin handler
|
||||||
|
func NewAdminHandler(db *database.DB, store *storage.Storage) *AdminHandler {
|
||||||
|
return &AdminHandler{
|
||||||
|
db: db,
|
||||||
|
store: store,
|
||||||
|
review: review.New(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard returns the admin dashboard with stats
|
||||||
|
func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
pending, approved, rejected, err := h.db.GetReviewStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"stats": map[string]int{
|
||||||
|
"pending": pending,
|
||||||
|
"approved": approved,
|
||||||
|
"rejected": rejected,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewQueue lists versions pending review
|
||||||
|
func (h *AdminHandler) ReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Parse pagination
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
versions, total, err := h.db.GetVersionsInReview(ctx, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"items": versions,
|
||||||
|
"pagination": map[string]int{
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": (total + limit - 1) / limit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewDetail returns details for a specific version under review
|
||||||
|
func (h *AdminHandler) ReviewDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
|
||||||
|
versionWithApp, err := h.db.GetVersionWithApp(ctx, versionID)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If package exists, run validation to get flags
|
||||||
|
var validationResult *review.FullValidationResult
|
||||||
|
if versionWithApp.Version.PackageURL != "" {
|
||||||
|
packagePath := h.store.GetPackagePath(versionWithApp.Version.PackageURL)
|
||||||
|
result, err := h.review.ValidatePackage(packagePath)
|
||||||
|
if err == nil {
|
||||||
|
validationResult = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"version": versionWithApp.Version,
|
||||||
|
"app": versionWithApp.App,
|
||||||
|
"developer": map[string]string{
|
||||||
|
"name": versionWithApp.DeveloperName,
|
||||||
|
"email": versionWithApp.DeveloperEmail,
|
||||||
|
},
|
||||||
|
"validation": validationResult,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveVersion approves a version
|
||||||
|
func (h *AdminHandler) ApproveVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req struct {
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
// Notes are optional, continue with empty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify version exists and is in review
|
||||||
|
version, err := h.db.GetVersion(ctx, versionID)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if version.Status != "in_review" {
|
||||||
|
Error(w, http.StatusBadRequest, "invalid_status", "Version is not in review")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve the version
|
||||||
|
if err := h.review.ApproveVersion(ctx, versionID, req.Notes); err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Version approved and published",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectVersion rejects a version
|
||||||
|
func (h *AdminHandler) RejectVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Reason == "" {
|
||||||
|
Error(w, http.StatusBadRequest, "missing_reason", "Rejection reason is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify version exists and is in review
|
||||||
|
version, err := h.db.GetVersion(ctx, versionID)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if version.Status != "in_review" {
|
||||||
|
Error(w, http.StatusBadRequest, "invalid_status", "Version is not in review")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject the version
|
||||||
|
feedback := &review.RejectionFeedback{
|
||||||
|
Reason: req.Reason,
|
||||||
|
Message: req.Message,
|
||||||
|
CanResubmit: true,
|
||||||
|
}
|
||||||
|
if err := h.review.RejectVersion(ctx, versionID, feedback); err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Version rejected",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePackage runs validation on a package and returns results
|
||||||
|
func (h *AdminHandler) ValidatePackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
|
||||||
|
// Get version with package info
|
||||||
|
version, err := h.db.GetVersion(ctx, versionID)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusNotFound, "not_found", "Version not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if version.PackageURL == "" {
|
||||||
|
Error(w, http.StatusBadRequest, "no_package", "Version has no uploaded package")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run validation
|
||||||
|
packagePath := h.store.GetPackagePath(version.PackageURL)
|
||||||
|
result, err := h.review.ValidatePackage(packagePath)
|
||||||
|
if err != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "validation_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
JSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
|||||||
authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db)
|
authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db)
|
||||||
appHandler := handlers.NewAppHandler(db, store)
|
appHandler := handlers.NewAppHandler(db, store)
|
||||||
storeHandler := handlers.NewStoreHandler(db, store)
|
storeHandler := handlers.NewStoreHandler(db, store)
|
||||||
|
adminHandler := handlers.NewAdminHandler(db, store)
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -127,14 +128,15 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Admin routes (htmx UI) - requires auth
|
// Admin API routes (JSON responses)
|
||||||
r.Route("/admin", func(r chi.Router) {
|
r.Route("/api/admin", func(r chi.Router) {
|
||||||
r.Use(authMiddleware.RequireAuth)
|
r.Use(authMiddleware.RequireAuth)
|
||||||
r.Get("/", handlers.NotImplemented)
|
r.Get("/stats", adminHandler.Dashboard)
|
||||||
r.Get("/review-queue", handlers.NotImplemented)
|
r.Get("/review-queue", adminHandler.ReviewQueue)
|
||||||
r.Get("/review/{versionID}", handlers.NotImplemented)
|
r.Get("/review/{versionID}", adminHandler.ReviewDetail)
|
||||||
r.Post("/review/{versionID}/approve", handlers.NotImplemented)
|
r.Post("/review/{versionID}/approve", adminHandler.ApproveVersion)
|
||||||
r.Post("/review/{versionID}/reject", handlers.NotImplemented)
|
r.Post("/review/{versionID}/reject", adminHandler.RejectVersion)
|
||||||
|
r.Get("/review/{versionID}/validate", adminHandler.ValidatePackage)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Web UI routes (htmx + Go templates)
|
// Web UI routes (htmx + Go templates)
|
||||||
@@ -142,6 +144,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Failed to initialize web handler: %v", err)
|
log.Printf("Warning: Failed to initialize web handler: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
webHandler.SetStorage(store)
|
||||||
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
|
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
|
||||||
|
|
||||||
// Public web pages
|
// Public web pages
|
||||||
@@ -163,6 +166,14 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
|||||||
|
|
||||||
// htmx partials
|
// htmx partials
|
||||||
r.Get("/partials/apps", webHandler.AppListPartial)
|
r.Get("/partials/apps", webHandler.AppListPartial)
|
||||||
|
|
||||||
|
// Admin pages (htmx UI)
|
||||||
|
r.Get("/admin/review-queue", webHandler.AdminReviewQueue)
|
||||||
|
r.Get("/admin/review/{versionID}", webHandler.AdminReviewDetail)
|
||||||
|
r.Get("/admin/partials/review-queue", webHandler.AdminReviewQueuePartial)
|
||||||
|
r.Post("/admin/review/{versionID}/approve", webHandler.AdminApprove)
|
||||||
|
r.Post("/admin/review/{versionID}/reject", webHandler.AdminReject)
|
||||||
|
r.Get("/admin/review/{versionID}/validate", webHandler.AdminValidate)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth callback that sets session (after OAuth)
|
// Auth callback that sets session (after OAuth)
|
||||||
|
|||||||
@@ -878,3 +878,209 @@ func (db *DB) GetPublishedVersionByCode(ctx context.Context, packageID string, v
|
|||||||
|
|
||||||
return scanVersion(row)
|
return scanVersion(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VersionWithApp combines version data with its parent app data for review display
|
||||||
|
type VersionWithApp struct {
|
||||||
|
Version *AppVersion `json:"version"`
|
||||||
|
App *App `json:"app"`
|
||||||
|
DeveloperName string `json:"developer_name"`
|
||||||
|
DeveloperEmail string `json:"developer_email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersionsInReview returns versions pending review with pagination
|
||||||
|
func (db *DB) GetVersionsInReview(ctx context.Context, limit, offset int) ([]VersionWithApp, int, error) {
|
||||||
|
// Get total count
|
||||||
|
var total int
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM app_versions WHERE status = 'in_review'
|
||||||
|
`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query versions with app and developer info
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature,
|
||||||
|
v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at,
|
||||||
|
a.id, a.developer_id, a.package_id, a.name, a.description, a.category, a.tags, a.status, a.created_at, a.updated_at,
|
||||||
|
d.name, d.email
|
||||||
|
FROM app_versions v
|
||||||
|
JOIN apps a ON a.id = v.app_id
|
||||||
|
JOIN developers d ON d.id = a.developer_id
|
||||||
|
WHERE v.status = 'in_review'
|
||||||
|
ORDER BY v.created_at ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []VersionWithApp
|
||||||
|
for rows.Next() {
|
||||||
|
var vwa VersionWithApp
|
||||||
|
var v AppVersion
|
||||||
|
var app App
|
||||||
|
|
||||||
|
var vPackageURL, vSignature, vPermsJSON, vMinVersion, vReleaseNotes, vReviewNotes sql.NullString
|
||||||
|
var vPublishedAt, vCreatedAt sql.NullString
|
||||||
|
var vPackageSize sql.NullInt64
|
||||||
|
var aDesc, aCat, aTagsJSON sql.NullString
|
||||||
|
var aCreatedAt, aUpdatedAt string
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &vPackageURL, &vPackageSize, &vSignature,
|
||||||
|
&vPermsJSON, &vMinVersion, &vReleaseNotes, &v.Status, &vReviewNotes, &vPublishedAt, &vCreatedAt,
|
||||||
|
&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &aDesc, &aCat, &aTagsJSON, &app.Status, &aCreatedAt, &aUpdatedAt,
|
||||||
|
&vwa.DeveloperName, &vwa.DeveloperEmail,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate version
|
||||||
|
v.PackageURL = vPackageURL.String
|
||||||
|
v.PackageSize = vPackageSize.Int64
|
||||||
|
v.Signature = vSignature.String
|
||||||
|
v.MinMosisVersion = vMinVersion.String
|
||||||
|
v.ReleaseNotes = vReleaseNotes.String
|
||||||
|
v.ReviewNotes = vReviewNotes.String
|
||||||
|
v.Permissions = []string{}
|
||||||
|
if vPermsJSON.Valid && vPermsJSON.String != "" {
|
||||||
|
json.Unmarshal([]byte(vPermsJSON.String), &v.Permissions)
|
||||||
|
}
|
||||||
|
if vCreatedAt.Valid {
|
||||||
|
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", vCreatedAt.String)
|
||||||
|
}
|
||||||
|
if vPublishedAt.Valid {
|
||||||
|
t, _ := time.Parse("2006-01-02 15:04:05", vPublishedAt.String)
|
||||||
|
v.PublishedAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate app
|
||||||
|
app.Description = aDesc.String
|
||||||
|
app.Category = aCat.String
|
||||||
|
app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aCreatedAt)
|
||||||
|
app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aUpdatedAt)
|
||||||
|
app.Tags = []string{}
|
||||||
|
if aTagsJSON.Valid && aTagsJSON.String != "" {
|
||||||
|
json.Unmarshal([]byte(aTagsJSON.String), &app.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
vwa.Version = &v
|
||||||
|
vwa.App = &app
|
||||||
|
results = append(results, vwa)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersionWithApp retrieves a version with its app and developer info
|
||||||
|
func (db *DB) GetVersionWithApp(ctx context.Context, versionID string) (*VersionWithApp, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `
|
||||||
|
SELECT v.id, v.app_id, v.version_code, v.version_name, v.package_url, v.package_size, v.signature,
|
||||||
|
v.permissions, v.min_mosis_version, v.release_notes, v.status, v.review_notes, v.published_at, v.created_at,
|
||||||
|
a.id, a.developer_id, a.package_id, a.name, a.description, a.category, a.tags, a.status, a.created_at, a.updated_at,
|
||||||
|
d.name, d.email
|
||||||
|
FROM app_versions v
|
||||||
|
JOIN apps a ON a.id = v.app_id
|
||||||
|
JOIN developers d ON d.id = a.developer_id
|
||||||
|
WHERE v.id = ?
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
var vwa VersionWithApp
|
||||||
|
var v AppVersion
|
||||||
|
var app App
|
||||||
|
|
||||||
|
var vPackageURL, vSignature, vPermsJSON, vMinVersion, vReleaseNotes, vReviewNotes sql.NullString
|
||||||
|
var vPublishedAt, vCreatedAt sql.NullString
|
||||||
|
var vPackageSize sql.NullInt64
|
||||||
|
var aDesc, aCat, aTagsJSON sql.NullString
|
||||||
|
var aCreatedAt, aUpdatedAt string
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&v.ID, &v.AppID, &v.VersionCode, &v.VersionName, &vPackageURL, &vPackageSize, &vSignature,
|
||||||
|
&vPermsJSON, &vMinVersion, &vReleaseNotes, &v.Status, &vReviewNotes, &vPublishedAt, &vCreatedAt,
|
||||||
|
&app.ID, &app.DeveloperID, &app.PackageID, &app.Name, &aDesc, &aCat, &aTagsJSON, &app.Status, &aCreatedAt, &aUpdatedAt,
|
||||||
|
&vwa.DeveloperName, &vwa.DeveloperEmail,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate version
|
||||||
|
v.PackageURL = vPackageURL.String
|
||||||
|
v.PackageSize = vPackageSize.Int64
|
||||||
|
v.Signature = vSignature.String
|
||||||
|
v.MinMosisVersion = vMinVersion.String
|
||||||
|
v.ReleaseNotes = vReleaseNotes.String
|
||||||
|
v.ReviewNotes = vReviewNotes.String
|
||||||
|
v.Permissions = []string{}
|
||||||
|
if vPermsJSON.Valid && vPermsJSON.String != "" {
|
||||||
|
json.Unmarshal([]byte(vPermsJSON.String), &v.Permissions)
|
||||||
|
}
|
||||||
|
if vCreatedAt.Valid {
|
||||||
|
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", vCreatedAt.String)
|
||||||
|
}
|
||||||
|
if vPublishedAt.Valid {
|
||||||
|
t, _ := time.Parse("2006-01-02 15:04:05", vPublishedAt.String)
|
||||||
|
v.PublishedAt = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate app
|
||||||
|
app.Description = aDesc.String
|
||||||
|
app.Category = aCat.String
|
||||||
|
app.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aCreatedAt)
|
||||||
|
app.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aUpdatedAt)
|
||||||
|
app.Tags = []string{}
|
||||||
|
if aTagsJSON.Valid && aTagsJSON.String != "" {
|
||||||
|
json.Unmarshal([]byte(aTagsJSON.String), &app.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
vwa.Version = &v
|
||||||
|
vwa.App = &app
|
||||||
|
return &vwa, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveVersion approves a version and optionally publishes it
|
||||||
|
func (db *DB) ApproveVersion(ctx context.Context, versionID, reviewerNotes string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE app_versions
|
||||||
|
SET status = 'published', review_notes = ?, published_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`, reviewerNotes, versionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update the app status to published
|
||||||
|
_, err = db.ExecContext(ctx, `
|
||||||
|
UPDATE apps SET status = 'published', updated_at = datetime('now')
|
||||||
|
WHERE id = (SELECT app_id FROM app_versions WHERE id = ?)
|
||||||
|
`, versionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectVersion rejects a version with feedback
|
||||||
|
func (db *DB) RejectVersion(ctx context.Context, versionID, reason, message string) error {
|
||||||
|
notes := reason
|
||||||
|
if message != "" {
|
||||||
|
notes = reason + ": " + message
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE app_versions SET status = 'rejected', review_notes = ? WHERE id = ?
|
||||||
|
`, notes, versionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReviewStats returns statistics about the review queue
|
||||||
|
func (db *DB) GetReviewStats(ctx context.Context) (pending, approved, rejected int, err error) {
|
||||||
|
err = db.QueryRowContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'in_review' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0)
|
||||||
|
FROM app_versions
|
||||||
|
`).Scan(&pending, &approved, &rejected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
405
portal/internal/review/service.go
Normal file
405
portal/internal/review/service.go
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
// Package review provides app review and validation services
|
||||||
|
package review
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/omixlab/mosis-portal/internal/database"
|
||||||
|
"github.com/omixlab/mosis-portal/pkg/mospkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReviewFlag represents a security or quality flag
|
||||||
|
type ReviewFlag struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Severity string `json:"severity"` // "info", "warning", "critical"
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullValidationResult extends ValidationResult with security flags
|
||||||
|
type FullValidationResult struct {
|
||||||
|
*mospkg.ValidationResult
|
||||||
|
Flags []ReviewFlag `json:"flags,omitempty"`
|
||||||
|
RequiresManual bool `json:"requires_manual"`
|
||||||
|
AutoApprovable bool `json:"auto_approvable"`
|
||||||
|
ValidationTimeMs int64 `json:"validation_time_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service handles app review operations
|
||||||
|
type Service struct {
|
||||||
|
db *database.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new review service
|
||||||
|
func New(db *database.DB) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePackage performs full validation on a package
|
||||||
|
func (s *Service) ValidatePackage(packagePath string) (*FullValidationResult, error) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Run basic validation
|
||||||
|
basicResult, err := mospkg.ValidatePackage(packagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &FullValidationResult{
|
||||||
|
ValidationResult: basicResult,
|
||||||
|
Flags: []ReviewFlag{},
|
||||||
|
AutoApprovable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If basic validation failed, no need to continue
|
||||||
|
if !basicResult.Valid {
|
||||||
|
result.AutoApprovable = false
|
||||||
|
result.ValidationTimeMs = time.Since(start).Milliseconds()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run security analysis (Tier 3)
|
||||||
|
flags, err := s.analyzeSecurityRisks(packagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Flags = append(result.Flags, flags...)
|
||||||
|
|
||||||
|
// Run quality checks (Tier 4)
|
||||||
|
qualityFlags := s.checkQuality(basicResult.Manifest)
|
||||||
|
result.Flags = append(result.Flags, qualityFlags...)
|
||||||
|
|
||||||
|
// Determine if manual review is required
|
||||||
|
result.RequiresManual = s.requiresManualReview(result)
|
||||||
|
if result.RequiresManual {
|
||||||
|
result.AutoApprovable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for critical flags
|
||||||
|
for _, flag := range result.Flags {
|
||||||
|
if flag.Severity == "critical" {
|
||||||
|
result.AutoApprovable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ValidationTimeMs = time.Since(start).Milliseconds()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitForReview submits a version for review
|
||||||
|
func (s *Service) SubmitForReview(ctx context.Context, versionID string) error {
|
||||||
|
return s.db.UpdateVersionStatus(ctx, versionID, "in_review")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveVersion approves a version
|
||||||
|
func (s *Service) ApproveVersion(ctx context.Context, versionID, reviewerNotes string) error {
|
||||||
|
return s.db.ApproveVersion(ctx, versionID, reviewerNotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectVersion rejects a version with feedback
|
||||||
|
func (s *Service) RejectVersion(ctx context.Context, versionID string, feedback *RejectionFeedback) error {
|
||||||
|
return s.db.RejectVersion(ctx, versionID, feedback.Reason, feedback.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectionFeedback contains rejection details
|
||||||
|
type RejectionFeedback struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details []RejectionDetail `json:"details,omitempty"`
|
||||||
|
CanResubmit bool `json:"can_resubmit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectionDetail provides specific feedback for an issue
|
||||||
|
type RejectionDetail struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
Issue string `json:"issue"`
|
||||||
|
Suggestion string `json:"suggestion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dangerous patterns to detect in Lua code
|
||||||
|
var dangerousPatterns = []struct {
|
||||||
|
Pattern *regexp.Regexp
|
||||||
|
Reason string
|
||||||
|
Severity string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`loadstring\s*\(`),
|
||||||
|
"Dynamic code execution via loadstring",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`load\s*\([^)]*\)`),
|
||||||
|
"Dynamic code loading",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`debug\s*\.\s*\w+`),
|
||||||
|
"Debug library usage",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`os\s*\.\s*execute`),
|
||||||
|
"OS command execution",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`os\s*\.\s*remove`),
|
||||||
|
"File deletion via os.remove",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`io\s*\.\s*(open|popen|read|write|lines)`),
|
||||||
|
"Direct file I/O operations",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`ffi\s*\.\s*\w+`),
|
||||||
|
"FFI (foreign function interface) usage",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`package\s*\.\s*loadlib`),
|
||||||
|
"Native library loading",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`package\s*\.\s*cpath`),
|
||||||
|
"C library path modification",
|
||||||
|
"critical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`rawset\s*\(\s*_G`),
|
||||||
|
"Global environment modification",
|
||||||
|
"warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`setfenv\s*\(`),
|
||||||
|
"Environment modification",
|
||||||
|
"warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`getfenv\s*\(`),
|
||||||
|
"Environment access",
|
||||||
|
"warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`https?://[^\s"']+`),
|
||||||
|
"Hardcoded external URL",
|
||||||
|
"info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
regexp.MustCompile(`require\s*\(\s*["'][^"']+["']\s*\)`),
|
||||||
|
"Module require (verify allowed modules)",
|
||||||
|
"info",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) analyzeSecurityRisks(packagePath string) ([]ReviewFlag, error) {
|
||||||
|
var flags []ReviewFlag
|
||||||
|
|
||||||
|
reader, err := zip.OpenReader(packagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(file.Name, "."))
|
||||||
|
if !strings.HasSuffix(file.Name, ".lua") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read Lua file content
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(rc, 1024*1024)) // 1MB limit
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr := string(content)
|
||||||
|
|
||||||
|
// Check against dangerous patterns
|
||||||
|
for _, dp := range dangerousPatterns {
|
||||||
|
if dp.Pattern.MatchString(contentStr) {
|
||||||
|
// Find line number
|
||||||
|
lineNum := findLineNumber(contentStr, dp.Pattern)
|
||||||
|
|
||||||
|
flags = append(flags, ReviewFlag{
|
||||||
|
Type: "SECURITY",
|
||||||
|
Severity: dp.Severity,
|
||||||
|
Reason: dp.Reason,
|
||||||
|
File: file.Name,
|
||||||
|
Line: lineNum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for obfuscated code (high entropy, meaningless variable names)
|
||||||
|
if isLikelyObfuscated(contentStr) {
|
||||||
|
flags = append(flags, ReviewFlag{
|
||||||
|
Type: "SECURITY",
|
||||||
|
Severity: "warning",
|
||||||
|
Reason: "Code appears to be obfuscated",
|
||||||
|
File: file.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ext // Unused but may be useful for future file type checks
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLineNumber(content string, pattern *regexp.Regexp) int {
|
||||||
|
loc := pattern.FindStringIndex(content)
|
||||||
|
if loc == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
lineNum := 1
|
||||||
|
for i := 0; i < loc[0] && i < len(content); i++ {
|
||||||
|
if content[i] == '\n' {
|
||||||
|
lineNum++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lineNum
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLikelyObfuscated(content string) bool {
|
||||||
|
// Simple heuristics for obfuscation detection:
|
||||||
|
// 1. High ratio of single-character variable names
|
||||||
|
// 2. Many string.char() calls
|
||||||
|
// 3. Long lines with minimal whitespace
|
||||||
|
|
||||||
|
singleCharVars := regexp.MustCompile(`\blocal\s+[a-z]\s*=`)
|
||||||
|
matches := singleCharVars.FindAllString(content, -1)
|
||||||
|
if len(matches) > 20 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
stringCharCalls := strings.Count(content, "string.char")
|
||||||
|
if stringCharCalls > 10 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for long lines (obfuscators often produce very long lines)
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
longLines := 0
|
||||||
|
for _, line := range lines {
|
||||||
|
if len(line) > 500 {
|
||||||
|
longLines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if longLines > 3 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) checkQuality(manifest *mospkg.Manifest) []ReviewFlag {
|
||||||
|
var flags []ReviewFlag
|
||||||
|
|
||||||
|
if manifest == nil {
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check description length
|
||||||
|
if len(manifest.Description) < 10 {
|
||||||
|
flags = append(flags, ReviewFlag{
|
||||||
|
Type: "QUALITY",
|
||||||
|
Severity: "info",
|
||||||
|
Reason: "Description is very short (less than 10 characters)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for placeholder icon paths
|
||||||
|
if manifest.Icons.Size32 == "" && manifest.Icons.Size64 == "" && manifest.Icons.Size128 == "" {
|
||||||
|
flags = append(flags, ReviewFlag{
|
||||||
|
Type: "QUALITY",
|
||||||
|
Severity: "warning",
|
||||||
|
Reason: "No icons specified in manifest",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for sensitive permissions
|
||||||
|
sensitivePerms := map[string]bool{
|
||||||
|
"camera": true,
|
||||||
|
"microphone": true,
|
||||||
|
"contacts": true,
|
||||||
|
"location": true,
|
||||||
|
}
|
||||||
|
for _, perm := range manifest.Permissions {
|
||||||
|
if sensitivePerms[perm] {
|
||||||
|
flags = append(flags, ReviewFlag{
|
||||||
|
Type: "PERMISSION",
|
||||||
|
Severity: "info",
|
||||||
|
Reason: fmt.Sprintf("App requests sensitive permission: %s", perm),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requiresManualReview(result *FullValidationResult) bool {
|
||||||
|
// Always require manual review for:
|
||||||
|
// 1. Any critical security flags
|
||||||
|
// 2. More than 3 warnings
|
||||||
|
// 3. Sensitive permissions
|
||||||
|
|
||||||
|
criticalCount := 0
|
||||||
|
warningCount := 0
|
||||||
|
hasSensitivePerms := false
|
||||||
|
|
||||||
|
for _, flag := range result.Flags {
|
||||||
|
switch flag.Severity {
|
||||||
|
case "critical":
|
||||||
|
criticalCount++
|
||||||
|
case "warning":
|
||||||
|
warningCount++
|
||||||
|
}
|
||||||
|
if flag.Type == "PERMISSION" {
|
||||||
|
hasSensitivePerms = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if criticalCount > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if warningCount > 3 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if hasSensitivePerms {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReviewQueue returns versions pending review
|
||||||
|
func (s *Service) GetReviewQueue(ctx context.Context, limit, offset int) ([]database.VersionWithApp, int, error) {
|
||||||
|
return s.db.GetVersionsInReview(ctx, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReviewDetails returns details for a specific version under review
|
||||||
|
func (s *Service) GetReviewDetails(ctx context.Context, versionID string) (*database.VersionWithApp, error) {
|
||||||
|
return s.db.GetVersionWithApp(ctx, versionID)
|
||||||
|
}
|
||||||
@@ -186,6 +186,17 @@ func (s *Storage) DeleteAppAssets(appID string) error {
|
|||||||
return os.RemoveAll(dir)
|
return os.RemoveAll(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPackagePath resolves a package URL (stored in DB) to a filesystem path
|
||||||
|
// Package URLs are stored as relative paths like "packages/{developerID}/{appID}/{versionCode}/package.mosis"
|
||||||
|
func (s *Storage) GetPackagePath(packageURL string) string {
|
||||||
|
// If it's already an absolute path, return as-is
|
||||||
|
if filepath.IsAbs(packageURL) {
|
||||||
|
return packageURL
|
||||||
|
}
|
||||||
|
// Otherwise, join with base path
|
||||||
|
return filepath.Join(s.basePath, packageURL)
|
||||||
|
}
|
||||||
|
|
||||||
// copyFile copies a file from src to dst
|
// copyFile copies a file from src to dst
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
in, err := os.Open(src)
|
in, err := os.Open(src)
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/omixlab/mosis-portal/internal/database"
|
"github.com/omixlab/mosis-portal/internal/database"
|
||||||
|
"github.com/omixlab/mosis-portal/internal/review"
|
||||||
|
"github.com/omixlab/mosis-portal/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles web page requests
|
// Handler handles web page requests
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
templates *Templates
|
templates *Templates
|
||||||
|
store *storage.Storage
|
||||||
|
review *review.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new web handler
|
// NewHandler creates a new web handler
|
||||||
@@ -22,9 +27,15 @@ func NewHandler(db *database.DB) (*Handler, error) {
|
|||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
|
review: review.New(db),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetStorage sets the storage instance for the handler
|
||||||
|
func (h *Handler) SetStorage(store *storage.Storage) {
|
||||||
|
h.store = store
|
||||||
|
}
|
||||||
|
|
||||||
// PageData is the base data structure for all pages
|
// PageData is the base data structure for all pages
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Title string
|
Title string
|
||||||
@@ -192,3 +203,236 @@ func getDeveloperFromContext(r *http.Request) *database.Developer {
|
|||||||
developer, _ := r.Context().Value("developer").(*database.Developer)
|
developer, _ := r.Context().Value("developer").(*database.Developer)
|
||||||
return developer
|
return developer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminReviewQueue renders the admin review queue page
|
||||||
|
func (h *Handler) AdminReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
developer := getDeveloperFromContext(r)
|
||||||
|
if developer == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add admin role check here
|
||||||
|
pending, approved, rejected, _ := h.db.GetReviewStats(r.Context())
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
PageData
|
||||||
|
Stats struct {
|
||||||
|
Pending int
|
||||||
|
Approved int
|
||||||
|
Rejected int
|
||||||
|
}
|
||||||
|
}{
|
||||||
|
PageData: PageData{
|
||||||
|
Title: "Review Queue",
|
||||||
|
ActiveNav: "admin",
|
||||||
|
Developer: developer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data.Stats.Pending = pending
|
||||||
|
data.Stats.Approved = approved
|
||||||
|
data.Stats.Rejected = rejected
|
||||||
|
|
||||||
|
h.render(w, "admin_review_queue", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminReviewDetail renders the review detail page
|
||||||
|
func (h *Handler) AdminReviewDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
developer := getDeveloperFromContext(r)
|
||||||
|
if developer == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
vwa, err := h.db.GetVersionWithApp(r.Context(), versionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Version not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run validation if package exists
|
||||||
|
var validationResult *review.FullValidationResult
|
||||||
|
if h.store != nil && vwa.Version.PackageURL != "" {
|
||||||
|
packagePath := h.store.GetPackagePath(vwa.Version.PackageURL)
|
||||||
|
result, err := h.review.ValidatePackage(packagePath)
|
||||||
|
if err == nil {
|
||||||
|
validationResult = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
PageData
|
||||||
|
App *database.App
|
||||||
|
Version *database.AppVersion
|
||||||
|
DeveloperName string
|
||||||
|
DeveloperEmail string
|
||||||
|
Validation *review.FullValidationResult
|
||||||
|
}{
|
||||||
|
PageData: PageData{
|
||||||
|
Title: "Review: " + vwa.App.Name,
|
||||||
|
ActiveNav: "admin",
|
||||||
|
Developer: developer,
|
||||||
|
},
|
||||||
|
App: vwa.App,
|
||||||
|
Version: vwa.Version,
|
||||||
|
DeveloperName: vwa.DeveloperName,
|
||||||
|
DeveloperEmail: vwa.DeveloperEmail,
|
||||||
|
Validation: validationResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.render(w, "admin_review_detail", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminReviewQueuePartial renders the review queue list partial for htmx
|
||||||
|
func (h *Handler) AdminReviewQueuePartial(w http.ResponseWriter, r *http.Request) {
|
||||||
|
developer := getDeveloperFromContext(r)
|
||||||
|
if developer == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit := 20
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
items, total, err := h.db.GetVersionsInReview(r.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to load queue", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Items []database.VersionWithApp
|
||||||
|
Pagination struct {
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
Total int
|
||||||
|
TotalPages int
|
||||||
|
}
|
||||||
|
}{
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
data.Pagination.Page = page
|
||||||
|
data.Pagination.Limit = limit
|
||||||
|
data.Pagination.Total = total
|
||||||
|
data.Pagination.TotalPages = (total + limit - 1) / limit
|
||||||
|
|
||||||
|
h.renderPartial(w, "review_queue_list", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminApprove handles approval of a version from the admin UI
|
||||||
|
func (h *Handler) AdminApprove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
developer := getDeveloperFromContext(r)
|
||||||
|
if developer == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
notes := r.FormValue("notes")
|
||||||
|
|
||||||
|
// Verify version exists and is in review
|
||||||
|
version, err := h.db.GetVersion(r.Context(), versionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Version not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if version.Status != "in_review" {
|
||||||
|
http.Error(w, "Version is not in review", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve the version
|
||||||
|
if err := h.review.ApproveVersion(r.Context(), versionID, notes); err != nil {
|
||||||
|
http.Error(w, "Failed to approve version: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to queue via htmx
|
||||||
|
w.Header().Set("HX-Redirect", "/admin/review-queue")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminReject handles rejection of a version from the admin UI
|
||||||
|
func (h *Handler) AdminReject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
developer := getDeveloperFromContext(r)
|
||||||
|
if developer == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
reason := r.FormValue("reason")
|
||||||
|
message := r.FormValue("message")
|
||||||
|
|
||||||
|
if reason == "" {
|
||||||
|
http.Error(w, "Rejection reason is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify version exists and is in review
|
||||||
|
version, err := h.db.GetVersion(r.Context(), versionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Version not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if version.Status != "in_review" {
|
||||||
|
http.Error(w, "Version is not in review", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject the version
|
||||||
|
feedback := &review.RejectionFeedback{
|
||||||
|
Reason: reason,
|
||||||
|
Message: message,
|
||||||
|
CanResubmit: true,
|
||||||
|
}
|
||||||
|
if err := h.review.RejectVersion(r.Context(), versionID, feedback); err != nil {
|
||||||
|
http.Error(w, "Failed to reject version: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to queue via htmx
|
||||||
|
w.Header().Set("HX-Redirect", "/admin/review-queue")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminValidate runs validation and returns HTML partial with results
|
||||||
|
func (h *Handler) AdminValidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
developer := getDeveloperFromContext(r)
|
||||||
|
if developer == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID := chi.URLParam(r, "versionID")
|
||||||
|
|
||||||
|
// Get version with package info
|
||||||
|
version, err := h.db.GetVersion(r.Context(), versionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Version not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if version.PackageURL == "" {
|
||||||
|
http.Error(w, "Version has no uploaded package", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run validation
|
||||||
|
packagePath := h.store.GetPackagePath(version.PackageURL)
|
||||||
|
result, err := h.review.ValidatePackage(packagePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Validation error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render validation results partial
|
||||||
|
h.renderPartial(w, "validation_results", result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ type Templates struct {
|
|||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Template helper functions
|
||||||
|
var templateFuncs = template.FuncMap{
|
||||||
|
"divFloat": func(a int64, b int) float64 {
|
||||||
|
return float64(a) / float64(b)
|
||||||
|
},
|
||||||
|
"add": func(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
},
|
||||||
|
"sub": func(a, b int) int {
|
||||||
|
return a - b
|
||||||
|
},
|
||||||
|
"mul": func(a, b int) int {
|
||||||
|
return a * b
|
||||||
|
},
|
||||||
|
"min": func(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// NewTemplates creates and parses all templates
|
// NewTemplates creates and parses all templates
|
||||||
func NewTemplates() (*Templates, error) {
|
func NewTemplates() (*Templates, error) {
|
||||||
t := &Templates{
|
t := &Templates{
|
||||||
@@ -47,7 +69,7 @@ func NewTemplates() (*Templates, error) {
|
|||||||
files := append([]string{pageFile}, baseFiles...)
|
files := append([]string{pageFile}, baseFiles...)
|
||||||
|
|
||||||
// Read and parse all files
|
// Read and parse all files
|
||||||
tmpl := template.New(filepath.Base(pageFile))
|
tmpl := template.New(filepath.Base(pageFile)).Funcs(templateFuncs)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
content, err := templateFS.ReadFile(file)
|
content, err := templateFS.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +92,7 @@ func NewTemplates() (*Templates, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tmpl, err := template.New(filepath.Base(partialFile)).Parse(string(content))
|
tmpl, err := template.New(filepath.Base(partialFile)).Funcs(templateFuncs).Parse(string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
206
portal/internal/web/templates/pages/admin_review_detail.html
Normal file
206
portal/internal/web/templates/pages/admin_review_detail.html
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mb-8">
|
||||||
|
<a href="/admin/review-queue" class="text-indigo-600 hover:text-indigo-800 text-sm mb-2 inline-flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to Queue
|
||||||
|
</a>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Review: {{.App.Name}}</h1>
|
||||||
|
<p class="text-gray-600 mt-1">Version {{.Version.VersionName}} ({{.Version.VersionCode}})</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<!-- App Info -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">App Information</h2>
|
||||||
|
<dl class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Package ID</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{.App.PackageID}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Category</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{if .App.Category}}{{.App.Category}}{{else}}Uncategorized{{end}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Developer</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{.DeveloperName}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Developer Email</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{.DeveloperEmail}}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<dt class="text-sm text-gray-600">Description</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{if .App.Description}}{{.App.Description}}{{else}}<span class="text-gray-400">No description</span>{{end}}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version Info -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Version Details</h2>
|
||||||
|
<dl class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Version</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{.Version.VersionName}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Version Code</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{.Version.VersionCode}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Package Size</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{printf "%.2f" (divFloat .Version.PackageSize 1048576)}} MB</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm text-gray-600">Submitted</dt>
|
||||||
|
<dd class="text-sm font-medium text-gray-900">{{.Version.CreatedAt.Format "Jan 2, 2006 3:04 PM"}}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<dt class="text-sm text-gray-600">Release Notes</dt>
|
||||||
|
<dd class="text-sm text-gray-900">{{if .Version.ReleaseNotes}}{{.Version.ReleaseNotes}}{{else}}<span class="text-gray-400">No release notes</span>{{end}}</dd>
|
||||||
|
</div>
|
||||||
|
{{if .Version.Permissions}}
|
||||||
|
<div class="col-span-2">
|
||||||
|
<dt class="text-sm text-gray-600 mb-2">Permissions</dt>
|
||||||
|
<dd class="flex flex-wrap gap-2">
|
||||||
|
{{range .Version.Permissions}}
|
||||||
|
<span class="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded">{{.}}</span>
|
||||||
|
{{end}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Validation Results -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Validation Results</h2>
|
||||||
|
<button
|
||||||
|
hx-get="/admin/review/{{.Version.ID}}/validate"
|
||||||
|
hx-target="#validation-results"
|
||||||
|
class="text-sm text-indigo-600 hover:text-indigo-800">
|
||||||
|
Re-run Validation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="validation-results">
|
||||||
|
{{if .Validation}}
|
||||||
|
{{if .Validation.Valid}}
|
||||||
|
<div class="flex items-center text-green-600 mb-4">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Package is valid
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="flex items-center text-red-600 mb-4">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Package validation failed
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Validation.Flags}}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{{range .Validation.Flags}}
|
||||||
|
<div class="flex items-start p-3 rounded-lg {{if eq .Severity "critical"}}bg-red-50 border border-red-200{{else if eq .Severity "warning"}}bg-yellow-50 border border-yellow-200{{else}}bg-blue-50 border border-blue-200{{end}}">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded {{if eq .Severity "critical"}}bg-red-100 text-red-800{{else if eq .Severity "warning"}}bg-yellow-100 text-yellow-800{{else}}bg-blue-100 text-blue-800{{end}}">
|
||||||
|
{{.Severity}}
|
||||||
|
</span>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-gray-900">{{.Reason}}</p>
|
||||||
|
{{if .File}}
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{.File}}{{if .Line}}:{{.Line}}{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-gray-500">No issues found.</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-gray-500">Validation not yet run. Click "Re-run Validation" to check the package.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Actions -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Status</h2>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full {{if eq .Version.Status "in_review"}}bg-yellow-500{{else if eq .Version.Status "published"}}bg-green-500{{else if eq .Version.Status "rejected"}}bg-red-500{{else}}bg-gray-500{{end}}"></span>
|
||||||
|
<span class="ml-2 text-sm font-medium text-gray-900 capitalize">{{.Version.Status}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .Validation}}
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{{if .Validation.AutoApprovable}}
|
||||||
|
<span class="text-green-600">Auto-approvable</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-yellow-600">Requires manual review</span>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
{{if eq .Version.Status "in_review"}}
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Actions</h2>
|
||||||
|
|
||||||
|
<!-- Approve -->
|
||||||
|
<form hx-post="/admin/review/{{.Version.ID}}/approve" hx-swap="none" class="mb-4">
|
||||||
|
<label class="block text-sm text-gray-600 mb-2">Approval Notes (optional)</label>
|
||||||
|
<textarea name="notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3" placeholder="Any notes for the developer..."></textarea>
|
||||||
|
<button type="submit" class="w-full px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
|
||||||
|
Approve & Publish
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Reject -->
|
||||||
|
<form hx-post="/admin/review/{{.Version.ID}}/reject" hx-swap="none">
|
||||||
|
<label class="block text-sm text-gray-600 mb-2">Rejection Reason *</label>
|
||||||
|
<select name="reason" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3">
|
||||||
|
<option value="">Select reason...</option>
|
||||||
|
<option value="security">Security Issue</option>
|
||||||
|
<option value="quality">Quality Standards</option>
|
||||||
|
<option value="content">Content Policy Violation</option>
|
||||||
|
<option value="functionality">Functionality Issues</option>
|
||||||
|
<option value="metadata">Metadata Issues</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<label class="block text-sm text-gray-600 mb-2">Additional Details</label>
|
||||||
|
<textarea name="message" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3" placeholder="Specific feedback for the developer..."></textarea>
|
||||||
|
<button type="submit" class="w-full px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Download Package -->
|
||||||
|
{{if .Version.PackageURL}}
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Package</h2>
|
||||||
|
<a href="/downloads/{{.Version.PackageURL}}" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||||
|
</svg>
|
||||||
|
Download Package
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
70
portal/internal/web/templates/pages/admin_review_queue.html
Normal file
70
portal/internal/web/templates/pages/admin_review_queue.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Review Queue</h1>
|
||||||
|
<p class="text-gray-600 mt-1">Apps pending review and approval.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Pending Review</p>
|
||||||
|
<p class="text-3xl font-bold text-yellow-600 mt-1">{{.Stats.Pending}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Approved</p>
|
||||||
|
<p class="text-3xl font-bold text-green-600 mt-1">{{.Stats.Approved}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Rejected</p>
|
||||||
|
<p class="text-3xl font-bold text-red-600 mt-1">{{.Stats.Rejected}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Review Queue -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Pending Reviews</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="review-queue" hx-get="/admin/partials/review-queue" hx-trigger="load" class="divide-y divide-gray-200">
|
||||||
|
<div class="p-8 text-center text-gray-500">
|
||||||
|
<div class="htmx-indicator inline-flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Loading review queue...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{{if .Items}}
|
||||||
|
{{range .Items}}
|
||||||
|
<a href="/admin/review/{{.Version.ID}}" class="block px-6 py-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center min-w-0">
|
||||||
|
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{{.App.Name}}</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">{{.App.PackageID}} - v{{.Version.VersionName}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-sm text-gray-500">{{.DeveloperName}}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{.Version.CreatedAt.Format "Jan 2, 2006"}}</p>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded bg-yellow-100 text-yellow-800">In Review</span>
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if gt .Pagination.TotalPages 1}}
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Showing {{add (mul (sub .Pagination.Page 1) .Pagination.Limit) 1}} to {{min (mul .Pagination.Page .Pagination.Limit) .Pagination.Total}} of {{.Pagination.Total}} results
|
||||||
|
</p>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
{{if gt .Pagination.Page 1}}
|
||||||
|
<button
|
||||||
|
hx-get="/admin/partials/review-queue?page={{sub .Pagination.Page 1}}"
|
||||||
|
hx-target="#review-queue"
|
||||||
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50">
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
{{if lt .Pagination.Page .Pagination.TotalPages}}
|
||||||
|
<button
|
||||||
|
hx-get="/admin/partials/review-queue?page={{add .Pagination.Page 1}}"
|
||||||
|
hx-target="#review-queue"
|
||||||
|
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50">
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<div class="px-6 py-12 text-center">
|
||||||
|
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500">No apps pending review.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">All caught up!</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "validation_results"}}
|
||||||
|
{{if .Valid}}
|
||||||
|
<div class="flex items-center text-green-600 mb-4">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Package is valid
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="flex items-center text-red-600 mb-4">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Package validation failed
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Flags}}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{{range .Flags}}
|
||||||
|
<div class="flex items-start p-3 rounded-lg {{if eq .Severity "critical"}}bg-red-50 border border-red-200{{else if eq .Severity "warning"}}bg-yellow-50 border border-yellow-200{{else}}bg-blue-50 border border-blue-200{{end}}">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded {{if eq .Severity "critical"}}bg-red-100 text-red-800{{else if eq .Severity "warning"}}bg-yellow-100 text-yellow-800{{else}}bg-blue-100 text-blue-800{{end}}">
|
||||||
|
{{.Severity}}
|
||||||
|
</span>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-gray-900">{{.Reason}}</p>
|
||||||
|
{{if .File}}
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{.File}}{{if .Line}}:{{.Line}}{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-sm text-gray-500">No issues found.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .AutoApprovable}}
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<p class="text-sm text-green-600">Package is auto-approvable</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user