package web import ( "net/http" "strconv" "github.com/go-chi/chi/v5" "omixlab.com/mosis-portal/internal/database" "omixlab.com/mosis-portal/internal/review" "omixlab.com/mosis-portal/internal/storage" "omixlab.com/mosis-portal/internal/telemetry" ) // Handler handles web page requests type Handler struct { db *database.DB templates *Templates store *storage.Storage review *review.Service telemetry *telemetry.Service } // NewHandler creates a new web handler func NewHandler(db *database.DB) (*Handler, error) { templates, err := NewTemplates() if err != nil { return nil, err } 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 } // SetTelemetry sets the telemetry service for the handler func (h *Handler) SetTelemetry(ts *telemetry.Service) { h.telemetry = ts } // PageData is the base data structure for all pages type PageData struct { Title string ActiveNav string Developer *database.Developer Error string } // Login renders the login page func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { data := PageData{ Title: "Sign In", } // Check for error message if err := r.URL.Query().Get("error"); err != "" { data.Error = err } h.render(w, "login", data) } // Dashboard renders the dashboard page func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { developer := getDeveloperFromContext(r) if developer == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } // Get stats apps, total, _ := h.db.ListApps(r.Context(), developer.ID, "", 1, 100) data := struct { PageData Stats struct { TotalApps int Downloads int64 ActiveUsers int64 } Apps []*database.App }{ PageData: PageData{ Title: "Dashboard", ActiveNav: "dashboard", Developer: developer, }, Apps: apps, } data.Stats.TotalApps = total data.Stats.Downloads = 0 // TODO: implement data.Stats.ActiveUsers = 0 // TODO: implement h.render(w, "dashboard", data) } // AppNew renders the new app form func (h *Handler) AppNew(w http.ResponseWriter, r *http.Request) { developer := getDeveloperFromContext(r) if developer == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } data := PageData{ Title: "Create New App", ActiveNav: "apps", Developer: developer, } h.render(w, "app_new", data) } // AppDetail renders the app detail page func (h *Handler) AppDetail(w http.ResponseWriter, r *http.Request) { developer := getDeveloperFromContext(r) if developer == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { http.Error(w, "App not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developer.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } // Get latest version versions, totalVersions, _ := h.db.ListVersions(r.Context(), appID, "", 1, 1) var latestVersion *database.AppVersion if len(versions) > 0 { latestVersion = versions[0] } data := struct { PageData App *database.App Tab string LatestVersion *database.AppVersion TotalVersions int }{ PageData: PageData{ Title: app.Name, ActiveNav: "apps", Developer: developer, }, App: app, Tab: "overview", LatestVersion: latestVersion, TotalVersions: totalVersions, } h.render(w, "app_detail", data) } // AppListPartial renders the app list partial for htmx func (h *Handler) AppListPartial(w http.ResponseWriter, r *http.Request) { developer := getDeveloperFromContext(r) if developer == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } apps, _, err := h.db.ListApps(r.Context(), developer.ID, "", 1, 100) if err != nil { http.Error(w, "Failed to load apps", http.StatusInternalServerError) return } data := struct { Apps []*database.App }{ Apps: apps, } h.renderPartial(w, "app_list", data) } // render renders a full page template func (h *Handler) render(w http.ResponseWriter, name string, data interface{}) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := h.templates.RenderPage(w, name, data); err != nil { http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) } } // renderPartial renders a partial template func (h *Handler) renderPartial(w http.ResponseWriter, name string, data interface{}) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := h.templates.RenderPartial(w, name, data); err != nil { http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) } } // getDeveloperFromContext retrieves the developer from the request context // This will be set by the session middleware 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) } // AppAnalytics renders the app analytics page func (h *Handler) AppAnalytics(w http.ResponseWriter, r *http.Request) { developer := getDeveloperFromContext(r) if developer == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { http.Error(w, "App not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developer.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } // Get days parameter days := 30 if d := r.URL.Query().Get("days"); d != "" { if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 { days = parsed } } // Get analytics data var overview *telemetry.AnalyticsOverview var eventStats []telemetry.DailyStats var crashes []*telemetry.CrashGroup if h.telemetry != nil { overview, _ = h.telemetry.GetAnalyticsOverview(r.Context(), appID, days) eventStats, _ = h.telemetry.GetDailyStats(r.Context(), appID, "", days) crashes, _, _ = h.telemetry.GetCrashGroups(r.Context(), appID, "open", 5, 0) } if overview == nil { overview = &telemetry.AnalyticsOverview{} } data := struct { PageData App *database.App Days int Overview *telemetry.AnalyticsOverview EventStats []telemetry.DailyStats Crashes []*telemetry.CrashGroup }{ PageData: PageData{ Title: app.Name + " - Analytics", ActiveNav: "apps", Developer: developer, }, App: app, Days: days, Overview: overview, EventStats: eventStats, Crashes: crashes, } h.render(w, "app_analytics", data) } // AppCrashes renders the app crashes page func (h *Handler) AppCrashes(w http.ResponseWriter, r *http.Request) { developer := getDeveloperFromContext(r) if developer == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { http.Error(w, "App not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developer.ID { http.Error(w, "Forbidden", http.StatusForbidden) return } // Get status filter status := r.URL.Query().Get("status") if status == "" { status = "open" } // Pagination page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } limit := 20 offset := (page - 1) * limit // Get crashes var crashes []*telemetry.CrashGroup var total int if h.telemetry != nil { crashes, total, _ = h.telemetry.GetCrashGroups(r.Context(), appID, status, limit, offset) } data := struct { PageData App *database.App Status string Crashes []*telemetry.CrashGroup Pagination struct { Page int Limit int Total int TotalPages int } }{ PageData: PageData{ Title: app.Name + " - Crashes", ActiveNav: "apps", Developer: developer, }, App: app, Status: status, Crashes: crashes, } data.Pagination.Page = page data.Pagination.Limit = limit data.Pagination.Total = total data.Pagination.TotalPages = (total + limit - 1) / limit h.render(w, "app_crashes", data) }