584 lines
14 KiB
Go
584 lines
14 KiB
Go
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"
|
|
"github.com/omixlab/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)
|
|
}
|