add local filesystem storage for packages and assets with upload handlers

This commit is contained in:
2026-01-18 21:16:42 +01:00
parent 01a0ac68a4
commit 149736108e
7 changed files with 481 additions and 12 deletions

View File

@@ -2,23 +2,27 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/omixlab/mosis-portal/internal/api/middleware"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/storage"
)
// AppHandler handles app-related endpoints
type AppHandler struct {
db *database.DB
db *database.DB
storage *storage.Storage
}
// NewAppHandler creates a new app handler
func NewAppHandler(db *database.DB) *AppHandler {
return &AppHandler{db: db}
func NewAppHandler(db *database.DB, store *storage.Storage) *AppHandler {
return &AppHandler{db: db, storage: store}
}
// CreateAppRequest is the request body for creating an app
@@ -509,3 +513,144 @@ func isValidPackageID(id string) bool {
}
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,
})
}

View File

@@ -1,21 +1,24 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/storage"
)
// StoreHandler handles public store endpoints
type StoreHandler struct {
db *database.DB
db *database.DB
storage *storage.Storage
}
// NewStoreHandler creates a new store handler
func NewStoreHandler(db *database.DB) *StoreHandler {
return &StoreHandler{db: db}
func NewStoreHandler(db *database.DB, store *storage.Storage) *StoreHandler {
return &StoreHandler{db: db, storage: store}
}
// ListApps returns published apps for the store
@@ -69,14 +72,25 @@ func (h *StoreHandler) GetApp(w http.ResponseWriter, r *http.Request) {
func (h *StoreHandler) Download(w http.ResponseWriter, r *http.Request) {
packageID := chi.URLParam(r, "packageID")
// Get app to find developer ID
app, err := h.db.GetAppByPackageID(r.Context(), packageID)
if err != nil {
Error(w, "app not found", http.StatusNotFound)
return
}
version, err := h.db.GetLatestPublishedVersion(r.Context(), packageID)
if err != nil {
Error(w, "no published version found", http.StatusNotFound)
return
}
// Build download URL
downloadURL := fmt.Sprintf("/downloads/%s/%s/%d/package.mosis",
app.DeveloperID, app.ID, version.VersionCode)
JSON(w, http.StatusOK, map[string]interface{}{
"download_url": version.PackageURL,
"download_url": downloadURL,
"version": version.VersionName,
"version_code": version.VersionCode,
"size": version.PackageSize,
@@ -95,14 +109,25 @@ func (h *StoreHandler) DownloadVersion(w http.ResponseWriter, r *http.Request) {
return
}
// Get app to find developer ID
app, err := h.db.GetAppByPackageID(r.Context(), packageID)
if err != nil {
Error(w, "app not found", http.StatusNotFound)
return
}
version, err := h.db.GetPublishedVersionByCode(r.Context(), packageID, versionCode)
if err != nil {
Error(w, "version not found", http.StatusNotFound)
return
}
// Build download URL
downloadURL := fmt.Sprintf("/downloads/%s/%s/%d/package.mosis",
app.DeveloperID, app.ID, version.VersionCode)
JSON(w, http.StatusOK, map[string]interface{}{
"download_url": version.PackageURL,
"download_url": downloadURL,
"version": version.VersionName,
"version_code": version.VersionCode,
"size": version.PackageSize,

View File

@@ -12,6 +12,7 @@ import (
"github.com/omixlab/mosis-portal/internal/auth"
"github.com/omixlab/mosis-portal/internal/config"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/storage"
"github.com/omixlab/mosis-portal/internal/web"
)
@@ -25,6 +26,12 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
r.Use(chimw.RealIP)
r.Use(chimw.RequestID)
// Initialize storage
store, err := storage.New(cfg.StoragePath)
if err != nil {
log.Fatalf("Failed to initialize storage: %v", err)
}
// Initialize auth components
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
oauthManager := auth.NewOAuthManager(
@@ -34,8 +41,8 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
)
authMiddleware := middleware.NewAuthMiddleware(jwtManager, db)
authHandler := handlers.NewAuthHandler(oauthManager, jwtManager, db)
appHandler := handlers.NewAppHandler(db)
storeHandler := handlers.NewStoreHandler(db)
appHandler := handlers.NewAppHandler(db, store)
storeHandler := handlers.NewStoreHandler(db, store)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
@@ -73,6 +80,9 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
r.Patch("/{appID}", appHandler.Update)
r.Delete("/{appID}", appHandler.Delete)
// Icon upload
r.Post("/{appID}/icon", appHandler.UploadIcon)
// Versions
r.Route("/{appID}/versions", func(r chi.Router) {
r.Get("/", appHandler.ListVersions)
@@ -80,6 +90,9 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
r.Get("/{versionID}", appHandler.GetVersion)
r.Post("/{versionID}/submit", appHandler.SubmitVersion)
r.Post("/{versionID}/publish", appHandler.PublishVersion)
// Package upload
r.Post("/{versionID}/upload", appHandler.UploadPackage)
})
})
@@ -170,5 +183,14 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
})
}
// Static file servers for packages and assets
// Downloads - serve package files with proper headers
r.Handle("/downloads/*", http.StripPrefix("/downloads/",
http.FileServer(http.Dir(store.PackagesPath()))))
// Assets - serve icons and screenshots
r.Handle("/assets/*", http.StripPrefix("/assets/",
http.FileServer(http.Dir(store.AssetsPath()))))
return r
}