package handlers import ( "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "omixlab.com/mosis-portal/internal/api/middleware" "omixlab.com/mosis-portal/internal/database" "omixlab.com/mosis-portal/internal/storage" ) // AppHandler handles app-related endpoints type AppHandler struct { db *database.DB storage *storage.Storage } // NewAppHandler creates a new app handler func NewAppHandler(db *database.DB, store *storage.Storage) *AppHandler { return &AppHandler{db: db, storage: store} } // CreateAppRequest is the request body for creating an app type CreateAppRequest struct { PackageID string `json:"package_id" validate:"required,package_id"` Name string `json:"name" validate:"required,min=1,max=50"` Description string `json:"description,omitempty" validate:"max=500"` Category string `json:"category,omitempty" validate:"max=50"` } // UpdateAppRequest is the request body for updating an app type UpdateAppRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=50"` Description *string `json:"description,omitempty" validate:"omitempty,max=500"` Category *string `json:"category,omitempty" validate:"omitempty,max=50"` Tags []string `json:"tags,omitempty" validate:"omitempty,max=10,dive,max=30"` } // List lists the developer's apps func (h *AppHandler) List(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } // Parse query parameters status := r.URL.Query().Get("status") page, _ := strconv.Atoi(r.URL.Query().Get("page")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if page < 1 { page = 1 } if limit < 1 || limit > 100 { limit = 20 } apps, total, err := h.db.ListApps(r.Context(), developerID, status, page, limit) if err != nil { Error(w, "failed to list apps: "+err.Error(), http.StatusInternalServerError) return } JSON(w, http.StatusOK, map[string]interface{}{ "apps": apps, "total": total, "pagination": map[string]interface{}{ "page": page, "limit": limit, "total": total, "total_pages": (total + limit - 1) / limit, }, }) } // Create creates a new app func (h *AppHandler) Create(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } var req CreateAppRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, "invalid request body", http.StatusBadRequest) return } // Validate package ID format if !isValidPackageID(req.PackageID) { Error(w, "invalid package_id format (must be like com.example.app)", http.StatusBadRequest) return } // Check if package_id already exists existing, _ := h.db.GetAppByPackageID(r.Context(), req.PackageID) if existing != nil { Error(w, "package_id already exists", http.StatusConflict) return } app, err := h.db.CreateApp(r.Context(), &database.App{ DeveloperID: developerID, PackageID: req.PackageID, Name: req.Name, Description: req.Description, Category: req.Category, Status: "draft", }) if err != nil { Error(w, "failed to create app: "+err.Error(), http.StatusInternalServerError) return } JSON(w, http.StatusCreated, app) } // Get retrieves a specific app func (h *AppHandler) Get(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } JSON(w, http.StatusOK, app) } // Update updates an app func (h *AppHandler) Update(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } var req UpdateAppRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, "invalid request body", http.StatusBadRequest) return } // Apply updates if req.Name != nil { app.Name = *req.Name } if req.Description != nil { app.Description = *req.Description } if req.Category != nil { app.Category = *req.Category } if req.Tags != nil { app.Tags = req.Tags } if err := h.db.UpdateApp(r.Context(), app); err != nil { Error(w, "failed to update app: "+err.Error(), http.StatusInternalServerError) return } JSON(w, http.StatusOK, app) } // Delete deletes an app func (h *AppHandler) Delete(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } // Check if app has published versions hasPublished, err := h.db.AppHasPublishedVersions(r.Context(), appID) if err != nil { Error(w, "failed to check versions: "+err.Error(), http.StatusInternalServerError) return } if hasPublished { Error(w, "cannot delete app with published versions", http.StatusBadRequest) return } if err := h.db.DeleteApp(r.Context(), appID); err != nil { Error(w, "failed to delete app: "+err.Error(), http.StatusInternalServerError) return } JSON(w, http.StatusOK, map[string]bool{"success": true}) } // ListVersions lists versions for an app func (h *AppHandler) ListVersions(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } // Parse query parameters status := r.URL.Query().Get("status") page, _ := strconv.Atoi(r.URL.Query().Get("page")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if page < 1 { page = 1 } if limit < 1 || limit > 100 { limit = 20 } versions, total, err := h.db.ListVersions(r.Context(), appID, status, page, limit) if err != nil { Error(w, "failed to list versions: "+err.Error(), http.StatusInternalServerError) return } JSON(w, http.StatusOK, map[string]interface{}{ "versions": versions, "total": total, "pagination": map[string]interface{}{ "page": page, "limit": limit, "total": total, "total_pages": (total + limit - 1) / limit, }, }) } // CreateVersionRequest is the request body for creating a version type CreateVersionRequest struct { VersionName string `json:"version_name" validate:"required"` VersionCode int `json:"version_code" validate:"required,min=1"` ReleaseNotes string `json:"release_notes,omitempty" validate:"max=5000"` } // CreateVersion creates a new app version func (h *AppHandler) CreateVersion(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } var req CreateVersionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { Error(w, "invalid request body", http.StatusBadRequest) return } // Check if version code already exists exists, err := h.db.VersionCodeExists(r.Context(), appID, req.VersionCode) if err != nil { Error(w, "failed to check version: "+err.Error(), http.StatusInternalServerError) return } if exists { Error(w, "version_code already exists for this app", http.StatusConflict) return } version, err := h.db.CreateVersion(r.Context(), &database.AppVersion{ AppID: appID, VersionName: req.VersionName, VersionCode: req.VersionCode, ReleaseNotes: req.ReleaseNotes, Status: "draft", }) if err != nil { Error(w, "failed to create version: "+err.Error(), http.StatusInternalServerError) return } // Generate upload URL (for now, just a placeholder path) uploadURL := "/packages/" + appID + "/" + version.ID + ".mosis" uploadExpires := time.Now().Add(1 * time.Hour).Format(time.RFC3339) JSON(w, http.StatusCreated, map[string]interface{}{ "version": version, "upload_url": uploadURL, "upload_expires": uploadExpires, }) } // GetVersion retrieves a specific version func (h *AppHandler) GetVersion(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") versionID := chi.URLParam(r, "versionID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } version, err := h.db.GetVersion(r.Context(), versionID) if err != nil || version.AppID != appID { Error(w, "version not found", http.StatusNotFound) return } JSON(w, http.StatusOK, version) } // SubmitVersion submits a version for review func (h *AppHandler) SubmitVersion(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") versionID := chi.URLParam(r, "versionID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } version, err := h.db.GetVersion(r.Context(), versionID) if err != nil || version.AppID != appID { Error(w, "version not found", http.StatusNotFound) return } // Check current status if version.Status != "draft" && version.Status != "rejected" { Error(w, "version cannot be submitted (current status: "+version.Status+")", http.StatusBadRequest) return } // Check if package is uploaded if version.PackageURL == "" { Error(w, "package must be uploaded before submitting", http.StatusBadRequest) return } version.Status = "review" if err := h.db.UpdateVersionStatus(r.Context(), versionID, "review"); err != nil { Error(w, "failed to submit version: "+err.Error(), http.StatusInternalServerError) return } JSON(w, http.StatusOK, version) } // PublishVersion publishes an approved version func (h *AppHandler) PublishVersion(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") versionID := chi.URLParam(r, "versionID") app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } // Verify ownership if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } version, err := h.db.GetVersion(r.Context(), versionID) if err != nil || version.AppID != appID { Error(w, "version not found", http.StatusNotFound) return } // Check current status - must be approved if version.Status != "approved" { Error(w, "version must be approved before publishing (current status: "+version.Status+")", http.StatusBadRequest) return } now := time.Now() version.Status = "published" version.PublishedAt = &now if err := h.db.PublishVersion(r.Context(), versionID); err != nil { Error(w, "failed to publish version: "+err.Error(), http.StatusInternalServerError) return } // Update app status to published if app.Status == "draft" { app.Status = "published" h.db.UpdateAppStatus(r.Context(), appID, "published") } JSON(w, http.StatusOK, version) } // isValidPackageID validates package ID format (com.example.app) func isValidPackageID(id string) bool { if len(id) < 5 || len(id) > 100 { return false } // Simple validation: lowercase letters, digits, and dots; must have at least 2 dots dots := 0 for i, c := range id { if c == '.' { dots++ // No consecutive dots, no leading/trailing dots if i == 0 || i == len(id)-1 { return false } continue } if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' { continue } return false } return dots >= 2 } // UploadPackage handles package file upload via multipart form func (h *AppHandler) UploadPackage(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") versionID := chi.URLParam(r, "versionID") // Verify app ownership app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } // Get version version, err := h.db.GetVersion(r.Context(), versionID) if err != nil || version.AppID != appID { Error(w, "version not found", http.StatusNotFound) return } // Check version status - must be draft if version.Status != "draft" { Error(w, "package can only be uploaded to draft versions", http.StatusBadRequest) return } // Parse multipart form (max 50MB) if err := r.ParseMultipartForm(50 << 20); err != nil { Error(w, "failed to parse form: "+err.Error(), http.StatusBadRequest) return } file, header, err := r.FormFile("package") if err != nil { Error(w, "no package file provided", http.StatusBadRequest) return } defer file.Close() // Validate file extension if header.Filename[len(header.Filename)-6:] != ".mosis" { Error(w, "package file must have .mosis extension", http.StatusBadRequest) return } // Generate upload ID and save to temp uploadID := uuid.New().String() size, err := h.storage.SaveTempFile(uploadID, file) if err != nil { Error(w, "failed to save package: "+err.Error(), http.StatusInternalServerError) return } // TODO: Validate package (signature, manifest extraction) // For now, just move to final location // Move to final storage if err := h.storage.MoveTempToFinal(uploadID, developerID, appID, version.VersionCode); err != nil { h.storage.DeleteTemp(uploadID) Error(w, "failed to store package: "+err.Error(), http.StatusInternalServerError) return } // Update version record with package info packagePath := h.storage.PackagePath(developerID, appID, version.VersionCode) version.PackageURL = packagePath version.PackageSize = size if err := h.db.UpdateVersionPackage(r.Context(), versionID, packagePath, size); err != nil { Error(w, "failed to update version: "+err.Error(), http.StatusInternalServerError) return } JSON(w, http.StatusOK, map[string]interface{}{ "success": true, "package_size": size, "package_url": fmt.Sprintf("/downloads/%s/%s/%d/package.mosis", developerID, appID, version.VersionCode), }) } // UploadIcon handles app icon upload func (h *AppHandler) UploadIcon(w http.ResponseWriter, r *http.Request) { developerID := middleware.GetDeveloperID(r.Context()) if developerID == "" { Error(w, "unauthorized", http.StatusUnauthorized) return } appID := chi.URLParam(r, "appID") // Verify app ownership app, err := h.db.GetApp(r.Context(), appID) if err != nil { Error(w, "app not found", http.StatusNotFound) return } if app.DeveloperID != developerID { Error(w, "forbidden", http.StatusForbidden) return } // Parse multipart form (max 10MB) if err := r.ParseMultipartForm(10 << 20); err != nil { Error(w, "failed to parse form: "+err.Error(), http.StatusBadRequest) return } file, _, err := r.FormFile("icon") if err != nil { Error(w, "no icon file provided", http.StatusBadRequest) return } defer file.Close() // Process icon (validate, resize to multiple sizes) if err := h.storage.ProcessIcon(appID, file); err != nil { Error(w, "failed to process icon: "+err.Error(), http.StatusBadRequest) return } // Return icon URLs iconURLs := make(map[string]string) for _, size := range storage.IconSizes { iconURLs[fmt.Sprintf("%d", size)] = fmt.Sprintf("/assets/%s/icon-%d.png", appID, size) } JSON(w, http.StatusOK, map[string]interface{}{ "success": true, "icons": iconURLs, }) }