diff --git a/portal/internal/api/handlers/admin.go b/portal/internal/api/handlers/admin.go new file mode 100644 index 0000000..f916ba8 --- /dev/null +++ b/portal/internal/api/handlers/admin.go @@ -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) +} diff --git a/portal/internal/api/router.go b/portal/internal/api/router.go index 4cad765..391f51f 100644 --- a/portal/internal/api/router.go +++ b/portal/internal/api/router.go @@ -43,6 +43,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler { authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db) appHandler := handlers.NewAppHandler(db, store) storeHandler := handlers.NewStoreHandler(db, store) + adminHandler := handlers.NewAdminHandler(db, store) // Health check 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 - r.Route("/admin", func(r chi.Router) { + // Admin API routes (JSON responses) + r.Route("/api/admin", func(r chi.Router) { r.Use(authMiddleware.RequireAuth) - r.Get("/", handlers.NotImplemented) - r.Get("/review-queue", handlers.NotImplemented) - r.Get("/review/{versionID}", handlers.NotImplemented) - r.Post("/review/{versionID}/approve", handlers.NotImplemented) - r.Post("/review/{versionID}/reject", handlers.NotImplemented) + r.Get("/stats", adminHandler.Dashboard) + r.Get("/review-queue", adminHandler.ReviewQueue) + r.Get("/review/{versionID}", adminHandler.ReviewDetail) + r.Post("/review/{versionID}/approve", adminHandler.ApproveVersion) + r.Post("/review/{versionID}/reject", adminHandler.RejectVersion) + r.Get("/review/{versionID}/validate", adminHandler.ValidatePackage) }) // Web UI routes (htmx + Go templates) @@ -142,6 +144,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler { if err != nil { log.Printf("Warning: Failed to initialize web handler: %v", err) } else { + webHandler.SetStorage(store) sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret) // Public web pages @@ -163,6 +166,14 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler { // htmx partials 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) diff --git a/portal/internal/database/database.go b/portal/internal/database/database.go index 6017fb9..41d0b5a 100644 --- a/portal/internal/database/database.go +++ b/portal/internal/database/database.go @@ -878,3 +878,209 @@ func (db *DB) GetPublishedVersionByCode(ctx context.Context, packageID string, v 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 +} diff --git a/portal/internal/review/service.go b/portal/internal/review/service.go new file mode 100644 index 0000000..05fbdc2 --- /dev/null +++ b/portal/internal/review/service.go @@ -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) +} diff --git a/portal/internal/storage/storage.go b/portal/internal/storage/storage.go index a6bac63..5ca6331 100644 --- a/portal/internal/storage/storage.go +++ b/portal/internal/storage/storage.go @@ -186,6 +186,17 @@ func (s *Storage) DeleteAppAssets(appID string) error { 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 func copyFile(src, dst string) error { in, err := os.Open(src) diff --git a/portal/internal/web/handlers.go b/portal/internal/web/handlers.go index b57e13e..2ee6d47 100644 --- a/portal/internal/web/handlers.go +++ b/portal/internal/web/handlers.go @@ -2,15 +2,20 @@ package web import ( "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" ) // Handler handles web page requests type Handler struct { db *database.DB templates *Templates + store *storage.Storage + review *review.Service } // NewHandler creates a new web handler @@ -22,9 +27,15 @@ func NewHandler(db *database.DB) (*Handler, error) { return &Handler{ db: db, templates: templates, + review: review.New(db), }, 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 type PageData struct { Title string @@ -192,3 +203,236 @@ func getDeveloperFromContext(r *http.Request) *database.Developer { developer, _ := r.Context().Value("developer").(*database.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) +} diff --git a/portal/internal/web/templates.go b/portal/internal/web/templates.go index 62b6aad..7860dfc 100644 --- a/portal/internal/web/templates.go +++ b/portal/internal/web/templates.go @@ -17,6 +17,28 @@ type Templates struct { 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 func NewTemplates() (*Templates, error) { t := &Templates{ @@ -47,7 +69,7 @@ func NewTemplates() (*Templates, error) { files := append([]string{pageFile}, baseFiles...) // Read and parse all files - tmpl := template.New(filepath.Base(pageFile)) + tmpl := template.New(filepath.Base(pageFile)).Funcs(templateFuncs) for _, file := range files { content, err := templateFS.ReadFile(file) if err != nil { @@ -70,7 +92,7 @@ func NewTemplates() (*Templates, error) { if err != nil { 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 { return nil, err } diff --git a/portal/internal/web/templates/pages/admin_review_detail.html b/portal/internal/web/templates/pages/admin_review_detail.html new file mode 100644 index 0000000..b86144f --- /dev/null +++ b/portal/internal/web/templates/pages/admin_review_detail.html @@ -0,0 +1,206 @@ +{{define "content"}} +
+ + + + + Back to Queue + +

Review: {{.App.Name}}

+

Version {{.Version.VersionName}} ({{.Version.VersionCode}})

+
+ +
+ +
+ +
+

App Information

+
+
+
Package ID
+
{{.App.PackageID}}
+
+
+
Category
+
{{if .App.Category}}{{.App.Category}}{{else}}Uncategorized{{end}}
+
+
+
Developer
+
{{.DeveloperName}}
+
+
+
Developer Email
+
{{.DeveloperEmail}}
+
+
+
Description
+
{{if .App.Description}}{{.App.Description}}{{else}}No description{{end}}
+
+
+
+ + +
+

Version Details

+
+
+
Version
+
{{.Version.VersionName}}
+
+
+
Version Code
+
{{.Version.VersionCode}}
+
+
+
Package Size
+
{{printf "%.2f" (divFloat .Version.PackageSize 1048576)}} MB
+
+
+
Submitted
+
{{.Version.CreatedAt.Format "Jan 2, 2006 3:04 PM"}}
+
+
+
Release Notes
+
{{if .Version.ReleaseNotes}}{{.Version.ReleaseNotes}}{{else}}No release notes{{end}}
+
+ {{if .Version.Permissions}} +
+
Permissions
+
+ {{range .Version.Permissions}} + {{.}} + {{end}} +
+
+ {{end}} +
+
+ + +
+
+

Validation Results

+ +
+
+ {{if .Validation}} + {{if .Validation.Valid}} +
+ + + + Package is valid +
+ {{else}} +
+ + + + Package validation failed +
+ {{end}} + + {{if .Validation.Flags}} +
+ {{range .Validation.Flags}} +
+ + {{.Severity}} + +
+

{{.Reason}}

+ {{if .File}} +

{{.File}}{{if .Line}}:{{.Line}}{{end}}

+ {{end}} +
+
+ {{end}} +
+ {{else}} +

No issues found.

+ {{end}} + {{else}} +

Validation not yet run. Click "Re-run Validation" to check the package.

+ {{end}} +
+
+
+ + +
+ +
+

Status

+
+ + {{.Version.Status}} +
+ {{if .Validation}} +
+

+ {{if .Validation.AutoApprovable}} + Auto-approvable + {{else}} + Requires manual review + {{end}} +

+
+ {{end}} +
+ + + {{if eq .Version.Status "in_review"}} +
+

Actions

+ + +
+ + + +
+ + +
+ + + + + +
+
+ {{end}} + + + {{if .Version.PackageURL}} + + {{end}} +
+
+{{end}} diff --git a/portal/internal/web/templates/pages/admin_review_queue.html b/portal/internal/web/templates/pages/admin_review_queue.html new file mode 100644 index 0000000..bb8f4c9 --- /dev/null +++ b/portal/internal/web/templates/pages/admin_review_queue.html @@ -0,0 +1,70 @@ +{{define "content"}} +
+

Review Queue

+

Apps pending review and approval.

+
+ + +
+
+
+
+

Pending Review

+

{{.Stats.Pending}}

+
+
+ + + +
+
+
+ +
+
+
+

Approved

+

{{.Stats.Approved}}

+
+
+ + + +
+
+
+ +
+
+
+

Rejected

+

{{.Stats.Rejected}}

+
+
+ + + +
+
+
+
+ + +
+
+

Pending Reviews

+
+ +
+
+
+ + + + + Loading review queue... +
+
+
+
+{{end}} diff --git a/portal/internal/web/templates/partials/review_queue_list.html b/portal/internal/web/templates/partials/review_queue_list.html new file mode 100644 index 0000000..c4354b8 --- /dev/null +++ b/portal/internal/web/templates/partials/review_queue_list.html @@ -0,0 +1,63 @@ +{{if .Items}} +{{range .Items}} + +
+
+
+ + + +
+
+

{{.App.Name}}

+

{{.App.PackageID}} - v{{.Version.VersionName}}

+
+
+
+
+

{{.DeveloperName}}

+

{{.Version.CreatedAt.Format "Jan 2, 2006"}}

+
+ In Review + + + +
+
+
+{{end}} + +{{if gt .Pagination.TotalPages 1}} +
+

+ Showing {{add (mul (sub .Pagination.Page 1) .Pagination.Limit) 1}} to {{min (mul .Pagination.Page .Pagination.Limit) .Pagination.Total}} of {{.Pagination.Total}} results +

+
+ {{if gt .Pagination.Page 1}} + + {{end}} + {{if lt .Pagination.Page .Pagination.TotalPages}} + + {{end}} +
+
+{{end}} +{{else}} +
+ + + +

No apps pending review.

+

All caught up!

+
+{{end}} diff --git a/portal/internal/web/templates/partials/validation_results.html b/portal/internal/web/templates/partials/validation_results.html new file mode 100644 index 0000000..860ab32 --- /dev/null +++ b/portal/internal/web/templates/partials/validation_results.html @@ -0,0 +1,43 @@ +{{define "validation_results"}} +{{if .Valid}} +
+ + + + Package is valid +
+{{else}} +
+ + + + Package validation failed +
+{{end}} + +{{if .Flags}} +
+ {{range .Flags}} +
+ + {{.Severity}} + +
+

{{.Reason}}

+ {{if .File}} +

{{.File}}{{if .Line}}:{{.Line}}{{end}}

+ {{end}} +
+
+ {{end}} +
+{{else}} +

No issues found.

+{{end}} + +{{if .AutoApprovable}} +
+

Package is auto-approvable

+
+{{end}} +{{end}}