diff --git a/portal/internal/api/handlers/apps.go b/portal/internal/api/handlers/apps.go index 0440723..ac58eec 100644 --- a/portal/internal/api/handlers/apps.go +++ b/portal/internal/api/handlers/apps.go @@ -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, + }) +} diff --git a/portal/internal/api/handlers/store.go b/portal/internal/api/handlers/store.go index 343ece9..ae267d4 100644 --- a/portal/internal/api/handlers/store.go +++ b/portal/internal/api/handlers/store.go @@ -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, diff --git a/portal/internal/api/router.go b/portal/internal/api/router.go index 9bff491..4cad765 100644 --- a/portal/internal/api/router.go +++ b/portal/internal/api/router.go @@ -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 } diff --git a/portal/internal/config/config.go b/portal/internal/config/config.go index 0fd1f66..a4ad0ca 100644 --- a/portal/internal/config/config.go +++ b/portal/internal/config/config.go @@ -26,7 +26,7 @@ type Config struct { GoogleClientSecret string // Storage - PackagesDir string + StoragePath string // Base path for all storage (packages/, assets/, temp/) BackupsDir string } @@ -45,7 +45,7 @@ func Load() (*Config, error) { GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), - PackagesDir: getEnv("PACKAGES_DIR", "./packages"), + StoragePath: getEnv("STORAGE_PATH", "./storage"), BackupsDir: getEnv("BACKUPS_DIR", "./backups"), } diff --git a/portal/internal/database/database.go b/portal/internal/database/database.go index d6a9fe9..6017fb9 100644 --- a/portal/internal/database/database.go +++ b/portal/internal/database/database.go @@ -698,6 +698,14 @@ func (db *DB) PublishVersion(ctx context.Context, id string) error { return err } +// UpdateVersionPackage updates the package_url and package_size for a version +func (db *DB) UpdateVersionPackage(ctx context.Context, id, packageURL string, packageSize int64) error { + _, err := db.ExecContext(ctx, ` + UPDATE app_versions SET package_url = ?, package_size = ? WHERE id = ? + `, packageURL, packageSize, id) + return err +} + // PublicApp represents a published app for the store type PublicApp struct { PackageID string `json:"package_id"` diff --git a/portal/internal/storage/icons.go b/portal/internal/storage/icons.go new file mode 100644 index 0000000..9defe75 --- /dev/null +++ b/portal/internal/storage/icons.go @@ -0,0 +1,64 @@ +package storage + +import ( + "bytes" + "fmt" + "image" + "image/png" + "io" + + // Import image formats for decoding + _ "image/gif" + _ "image/jpeg" + + "golang.org/x/image/draw" +) + +// IconSizes defines the icon sizes to generate +var IconSizes = []int{32, 64, 128} + +// ProcessIcon reads an image and generates icons at multiple sizes +func (s *Storage) ProcessIcon(appID string, r io.Reader) error { + // Decode the image + img, _, err := image.Decode(r) + if err != nil { + return fmt.Errorf("failed to decode image: %w", err) + } + + // Validate square dimensions + bounds := img.Bounds() + if bounds.Dx() != bounds.Dy() { + return fmt.Errorf("icon must be square, got %dx%d", bounds.Dx(), bounds.Dy()) + } + + // Minimum size check + if bounds.Dx() < 128 { + return fmt.Errorf("icon must be at least 128x128, got %dx%d", bounds.Dx(), bounds.Dy()) + } + + // Generate each size + for _, size := range IconSizes { + resized := resizeImage(img, size, size) + + var buf bytes.Buffer + if err := png.Encode(&buf, resized); err != nil { + return fmt.Errorf("failed to encode icon-%d: %w", size, err) + } + + if err := s.SaveIcon(appID, size, buf.Bytes()); err != nil { + return fmt.Errorf("failed to save icon-%d: %w", size, err) + } + } + + return nil +} + +// resizeImage resizes an image to the specified dimensions using high-quality interpolation +func resizeImage(src image.Image, width, height int) image.Image { + dst := image.NewRGBA(image.Rect(0, 0, width, height)) + + // Use CatmullRom for high-quality downscaling + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + + return dst +} diff --git a/portal/internal/storage/storage.go b/portal/internal/storage/storage.go new file mode 100644 index 0000000..a6bac63 --- /dev/null +++ b/portal/internal/storage/storage.go @@ -0,0 +1,205 @@ +// Package storage handles file storage for packages and assets +package storage + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// Storage manages file storage on the local filesystem +type Storage struct { + basePath string +} + +// New creates a new Storage instance +func New(basePath string) (*Storage, error) { + // Create base directories if they don't exist + dirs := []string{ + filepath.Join(basePath, "packages"), + filepath.Join(basePath, "assets"), + filepath.Join(basePath, "temp"), + } + + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + + return &Storage{basePath: basePath}, nil +} + +// BasePath returns the base storage path +func (s *Storage) BasePath() string { + return s.basePath +} + +// PackagesPath returns the path to the packages directory +func (s *Storage) PackagesPath() string { + return filepath.Join(s.basePath, "packages") +} + +// AssetsPath returns the path to the assets directory +func (s *Storage) AssetsPath() string { + return filepath.Join(s.basePath, "assets") +} + +// TempPath returns the path to the temp directory +func (s *Storage) TempPath() string { + return filepath.Join(s.basePath, "temp") +} + +// PackagePath returns the path for a specific package version +func (s *Storage) PackagePath(developerID, appID string, versionCode int) string { + return filepath.Join(s.basePath, "packages", developerID, appID, + fmt.Sprintf("%d", versionCode), "package.mosis") +} + +// PackageDir returns the directory for a specific package version +func (s *Storage) PackageDir(developerID, appID string, versionCode int) string { + return filepath.Join(s.basePath, "packages", developerID, appID, + fmt.Sprintf("%d", versionCode)) +} + +// TempPackagePath returns a temp path for uploading +func (s *Storage) TempPackagePath(uploadID string) string { + return filepath.Join(s.basePath, "temp", uploadID, "package.mosis") +} + +// IconPath returns the path for an app icon of a specific size +func (s *Storage) IconPath(appID string, size int) string { + return filepath.Join(s.basePath, "assets", appID, fmt.Sprintf("icon-%d.png", size)) +} + +// ScreenshotPath returns the path for an app screenshot +func (s *Storage) ScreenshotPath(appID string, index int) string { + return filepath.Join(s.basePath, "assets", appID, "screenshots", fmt.Sprintf("%d.png", index)) +} + +// SaveTempFile saves a file to temp storage +func (s *Storage) SaveTempFile(uploadID string, r io.Reader) (int64, error) { + path := s.TempPackagePath(uploadID) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return 0, fmt.Errorf("failed to create temp directory: %w", err) + } + + f, err := os.Create(path) + if err != nil { + return 0, fmt.Errorf("failed to create temp file: %w", err) + } + defer f.Close() + + size, err := io.Copy(f, r) + if err != nil { + os.Remove(path) + return 0, fmt.Errorf("failed to write temp file: %w", err) + } + + return size, nil +} + +// MoveTempToFinal moves a temp file to its final location +func (s *Storage) MoveTempToFinal(uploadID, developerID, appID string, versionCode int) error { + tempPath := s.TempPackagePath(uploadID) + finalPath := s.PackagePath(developerID, appID, versionCode) + + // Create final directory + if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { + return fmt.Errorf("failed to create package directory: %w", err) + } + + // Move file + if err := os.Rename(tempPath, finalPath); err != nil { + // If rename fails (cross-device), copy and delete + if err := copyFile(tempPath, finalPath); err != nil { + return fmt.Errorf("failed to move temp file: %w", err) + } + os.Remove(tempPath) + } + + // Clean up temp directory + os.Remove(filepath.Dir(tempPath)) + + return nil +} + +// DeleteTemp removes temp files for an upload +func (s *Storage) DeleteTemp(uploadID string) error { + tempDir := filepath.Join(s.basePath, "temp", uploadID) + return os.RemoveAll(tempDir) +} + +// SaveIcon saves an icon file +func (s *Storage) SaveIcon(appID string, size int, data []byte) error { + path := s.IconPath(appID, size) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create assets directory: %w", err) + } + + return os.WriteFile(path, data, 0644) +} + +// SaveScreenshot saves a screenshot +func (s *Storage) SaveScreenshot(appID string, index int, data []byte) error { + path := s.ScreenshotPath(appID, index) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create screenshots directory: %w", err) + } + + return os.WriteFile(path, data, 0644) +} + +// OpenPackage opens a package file for reading +func (s *Storage) OpenPackage(developerID, appID string, versionCode int) (*os.File, error) { + path := s.PackagePath(developerID, appID, versionCode) + return os.Open(path) +} + +// PackageExists checks if a package file exists +func (s *Storage) PackageExists(developerID, appID string, versionCode int) bool { + path := s.PackagePath(developerID, appID, versionCode) + _, err := os.Stat(path) + return err == nil +} + +// GetPackageSize returns the size of a package file +func (s *Storage) GetPackageSize(developerID, appID string, versionCode int) (int64, error) { + path := s.PackagePath(developerID, appID, versionCode) + info, err := os.Stat(path) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// DeletePackage removes a package and its directory +func (s *Storage) DeletePackage(developerID, appID string, versionCode int) error { + dir := s.PackageDir(developerID, appID, versionCode) + return os.RemoveAll(dir) +} + +// DeleteAppAssets removes all assets for an app +func (s *Storage) DeleteAppAssets(appID string) error { + dir := filepath.Join(s.basePath, "assets", appID) + return os.RemoveAll(dir) +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +}