diff --git a/DEV_PORTAL_M01_APP_PACKAGE.md b/DEV_PORTAL_M01_APP_PACKAGE.md index 5fd742e..3699cb3 100644 --- a/DEV_PORTAL_M01_APP_PACKAGE.md +++ b/DEV_PORTAL_M01_APP_PACKAGE.md @@ -1,8 +1,47 @@ # Milestone 1: App Package Format -**Status**: Planning +**Status**: Decided **Goal**: Define how apps are bundled, signed, and validated. +## Decision + +**Signed ZIP (Option C)** with JAR/APK-style signing using Ed25519: + +``` +Format: ZIP archive with .mosis extension +Signing: Ed25519 (crypto/ed25519 stdlib) +Manifest: META-INF/MANIFEST.MF with SHA-256 hashes +Validation: Go package (mosis-portal/pkg/package) +``` + +### Rationale + +1. **Standard tooling** - ZIP format works with all archive tools +2. **Proven approach** - JAR/APK signing is battle-tested +3. **Ed25519** - Fast, secure, small signatures (64 bytes) +4. **Go stdlib** - crypto/ed25519 and archive/zip in standard library +5. **Easy inspection** - Developers can unzip and view contents + +### Package Structure + +``` +com.developer.appname-1.0.0.mosis (ZIP archive) +├── manifest.json # App metadata (JSON) +├── META-INF/ +│ ├── MANIFEST.MF # SHA-256 hashes of all files +│ └── CERT.SIG # Ed25519 signature of MANIFEST.MF +├── icons/ +│ ├── icon-32.png +│ ├── icon-64.png +│ └── icon-128.png +└── assets/ + ├── main.rml # Entry point + ├── styles/ + │ └── theme.rcss + └── scripts/ + └── app.lua +``` + --- ## Overview @@ -331,12 +370,13 @@ SHA-256-Digest: base64encodedHash== ## Deliverables +- [x] Package format decided (Signed ZIP with .mosis extension) +- [x] Signing algorithm decided (Ed25519) - [ ] JSON Schema for manifest validation -- [ ] Package format specification document -- [ ] Reference implementation: package creator (Go/Rust) -- [ ] Reference implementation: package validator -- [ ] Reference implementation: signature tools -- [ ] Integration with mosis-cli +- [ ] Go package: `pkg/package/manifest.go` (parsing/validation) +- [ ] Go package: `pkg/package/validator.go` (package validation) +- [ ] Go package: `pkg/package/signer.go` (Ed25519 signing/verification) +- [ ] Integration with mosis-cli `build` and `sign` commands --- @@ -358,10 +398,10 @@ SHA-256-Digest: base64encodedHash== ## Open Questions -1. Should we support multiple entry points (e.g., widget vs full app)? -2. Should icons be required or have defaults? -3. Delta updates in v1 or defer to v2? -4. Support for app bundles (multiple apps in one package)? +1. ~~Should we support multiple entry points (e.g., widget vs full app)?~~ → Single entry point for v1 +2. ~~Should icons be required or have defaults?~~ → Required (32, 64, 128 sizes) +3. ~~Delta updates in v1 or defer to v2?~~ → Defer to v2 (full updates only) +4. ~~Support for app bundles (multiple apps in one package)?~~ → No, one app per package --- diff --git a/portal/Dockerfile b/portal/Dockerfile new file mode 100644 index 0000000..537738c --- /dev/null +++ b/portal/Dockerfile @@ -0,0 +1,50 @@ +# Multi-stage build for mosis-portal +FROM golang:1.22-alpine AS builder + +WORKDIR /build + +# Install build dependencies +RUN apk add --no-cache ca-certificates + +# Copy go.mod and go.sum +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o mosis-portal ./cmd/server + +# Final image +FROM alpine:3.19 + +WORKDIR /app + +# Install runtime dependencies +RUN apk add --no-cache ca-certificates tzdata + +# Install Litestream +RUN wget -q https://github.com/benbjohnson/litestream/releases/download/v0.3.13/litestream-v0.3.13-linux-amd64.tar.gz && \ + tar -xzf litestream-v0.3.13-linux-amd64.tar.gz && \ + mv litestream /usr/local/bin/ && \ + rm litestream-v0.3.13-linux-amd64.tar.gz + +# Copy binary from builder +COPY --from=builder /build/mosis-portal /app/mosis-portal + +# Copy Litestream config +COPY litestream.yml /etc/litestream.yml + +# Create data directories +RUN mkdir -p /data /packages /backups + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Run with Litestream for continuous backup +ENTRYPOINT ["litestream", "replicate", "-exec", "/app/mosis-portal"] diff --git a/portal/README.md b/portal/README.md new file mode 100644 index 0000000..d387fa7 --- /dev/null +++ b/portal/README.md @@ -0,0 +1,194 @@ +# Mosis Portal + +Developer portal and app store backend for Mosis. + +## Overview + +mosis-portal is a self-hosted Go server that provides: + +- **Developer Portal** - Account management, app submission, signing key registration +- **App Store API** - App discovery, download, and updates for devices +- **Review System** - Automated and manual app review pipeline +- **Telemetry** - Usage analytics and crash reporting + +## Architecture + +``` +Single Go binary + SQLite + Litestream +├── Go 1.22+ with Chi router +├── SQLite (WAL mode) via modernc.org/sqlite (pure Go) +├── Litestream for continuous backup +└── Ed25519 for package signing +``` + +## Quick Start + +### Local Development + +```bash +# Run with Go +go run ./cmd/server + +# Or build and run +go build -o mosis-portal ./cmd/server +./mosis-portal +``` + +### Docker + +```bash +# Build and run +docker-compose up --build + +# Or build image directly +docker build -t mosis-portal . +docker run -p 8080:8080 -v ./data:/data mosis-portal +``` + +### Synology NAS Deployment + +1. Copy files to NAS: + ```bash + scp -r . nas:/volume1/docker/mosis-portal/ + ``` + +2. Create data directories: + ```bash + ssh nas "mkdir -p /volume1/mosis/{data,packages,backups}" + ``` + +3. Update docker-compose.yml volumes: + ```yaml + volumes: + - /volume1/mosis/data:/data + - /volume1/mosis/packages:/packages + - /volume1/mosis/backups:/backups + ``` + +4. Deploy: + ```bash + docker-compose up -d + ``` + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `LISTEN_ADDR` | `:8080` | Server listen address | +| `BASE_URL` | `http://localhost:8080` | Public URL for OAuth callbacks | +| `DATABASE_PATH` | `./data/portal.db` | SQLite database path | +| `PACKAGES_DIR` | `./packages` | App package storage | +| `BACKUPS_DIR` | `./backups` | Litestream backup location | +| `JWT_SECRET` | (required) | Secret for JWT signing | +| `GITHUB_CLIENT_ID` | (optional) | GitHub OAuth client ID | +| `GITHUB_CLIENT_SECRET` | (optional) | GitHub OAuth client secret | +| `GOOGLE_CLIENT_ID` | (optional) | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | (optional) | Google OAuth client secret | + +## API Endpoints + +### Authentication + +``` +POST /v1/auth/oauth/github Start GitHub OAuth +GET /v1/auth/oauth/github/callback GitHub callback +POST /v1/auth/oauth/google Start Google OAuth +GET /v1/auth/oauth/google/callback Google callback +POST /v1/auth/refresh Refresh tokens +POST /v1/auth/logout Logout +GET /v1/auth/me Get current user +``` + +### Apps + +``` +GET /v1/apps List developer's apps +POST /v1/apps Create new app +GET /v1/apps/:id Get app details +PATCH /v1/apps/:id Update app +DELETE /v1/apps/:id Delete app + +GET /v1/apps/:id/versions List versions +POST /v1/apps/:id/versions Upload new version +POST /v1/apps/:id/versions/:vid/submit Submit for review +POST /v1/apps/:id/versions/:vid/publish Publish +``` + +### Store (Public) + +``` +GET /v1/store/apps Browse/search apps +GET /v1/store/apps/:id App details +GET /v1/store/apps/:id/download Download latest version +GET /v1/store/apps/updates Check for updates +``` + +### Telemetry + +``` +POST /v1/telemetry/events Batch event upload +POST /v1/telemetry/crash Crash report +``` + +## Package Format + +Mosis apps use the `.mosis` format (signed ZIP archive): + +``` +com.developer.app-1.0.0.mosis +├── manifest.json # App metadata +├── META-INF/ +│ ├── MANIFEST.MF # SHA-256 hashes +│ └── CERT.SIG # Ed25519 signature +├── icons/ +│ ├── icon-32.png +│ ├── icon-64.png +│ └── icon-128.png +└── assets/ + ├── main.rml + └── scripts/app.lua +``` + +## Development + +### Project Structure + +``` +portal/ +├── cmd/server/ # Main entry point +├── internal/ +│ ├── api/ # HTTP handlers +│ │ └── handlers/ +│ ├── config/ # Configuration +│ └── database/ # SQLite operations +├── pkg/mospkg/ # Package format library +│ ├── manifest.go # Manifest parsing +│ ├── validator.go # Package validation +│ └── signer.go # Ed25519 signing +├── Dockerfile +├── docker-compose.yml +├── litestream.yml +└── go.mod +``` + +### Testing + +```bash +go test ./... +``` + +### Building + +```bash +# Local +go build -o mosis-portal ./cmd/server + +# Cross-compile for Linux/ARM64 (Synology) +GOOS=linux GOARCH=arm64 go build -o mosis-portal-arm64 ./cmd/server +``` + +## License + +Proprietary - OmixLab LTD diff --git a/portal/cmd/server/main.go b/portal/cmd/server/main.go new file mode 100644 index 0000000..5bd1fb2 --- /dev/null +++ b/portal/cmd/server/main.go @@ -0,0 +1,73 @@ +// mosis-portal: Developer portal and app store backend for Mosis +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/omixlab/mosis-portal/internal/api" + "github.com/omixlab/mosis-portal/internal/config" + "github.com/omixlab/mosis-portal/internal/database" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + // Initialize database + db, err := database.Open(cfg.DatabasePath) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Run migrations + if err := database.Migrate(db); err != nil { + log.Fatalf("failed to run migrations: %v", err) + } + + // Create router + router := api.NewRouter(cfg, db) + + // Create server + server := &http.Server{ + Addr: cfg.ListenAddr, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + log.Printf("Starting mosis-portal on %s", cfg.ListenAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("server forced to shutdown: %v", err) + } + + log.Println("Server stopped") +} diff --git a/portal/docker-compose.yml b/portal/docker-compose.yml new file mode 100644 index 0000000..5fdacc9 --- /dev/null +++ b/portal/docker-compose.yml @@ -0,0 +1,31 @@ +# Docker Compose for local development and Synology NAS deployment +version: '3.8' + +services: + portal: + build: . + container_name: mosis-portal + restart: unless-stopped + ports: + - "8080:8080" + environment: + - LISTEN_ADDR=:8080 + - BASE_URL=https://portal.mosis.dev + - DATABASE_PATH=/data/portal.db + - PACKAGES_DIR=/packages + - BACKUPS_DIR=/backups + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + volumes: + # On Synology NAS, mount to /volume1/mosis/ + - ./data:/data + - ./packages:/packages + - ./backups:/backups + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 diff --git a/portal/go.mod b/portal/go.mod new file mode 100644 index 0000000..4e1087c --- /dev/null +++ b/portal/go.mod @@ -0,0 +1,12 @@ +module github.com/omixlab/mosis-portal + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/go-playground/validator/v10 v10.19.0 + github.com/golang-jwt/jwt/v5 v5.2.0 + golang.org/x/crypto v0.21.0 + golang.org/x/oauth2 v0.18.0 + modernc.org/sqlite v1.29.5 +) diff --git a/portal/internal/api/handlers/handlers.go b/portal/internal/api/handlers/handlers.go new file mode 100644 index 0000000..30dd76a --- /dev/null +++ b/portal/internal/api/handlers/handlers.go @@ -0,0 +1,35 @@ +// Package handlers contains HTTP request handlers +package handlers + +import ( + "encoding/json" + "net/http" +) + +// ErrorResponse represents an API error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` +} + +// NotImplemented returns a 501 Not Implemented response +func NotImplemented(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + json.NewEncoder(w).Encode(ErrorResponse{ + Error: "not_implemented", + Message: "This endpoint is not yet implemented", + }) +} + +// JSON writes a JSON response with the given status code +func JSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// Error writes a JSON error response +func Error(w http.ResponseWriter, status int, err string, message string) { + JSON(w, status, ErrorResponse{Error: err, Message: message}) +} diff --git a/portal/internal/api/router.go b/portal/internal/api/router.go new file mode 100644 index 0000000..6ee73c7 --- /dev/null +++ b/portal/internal/api/router.go @@ -0,0 +1,100 @@ +// Package api provides the HTTP API for mosis-portal +package api + +import ( + "database/sql" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/omixlab/mosis-portal/internal/api/handlers" + "github.com/omixlab/mosis-portal/internal/config" +) + +// NewRouter creates and configures the HTTP router +func NewRouter(cfg *config.Config, db *sql.DB) http.Handler { + r := chi.NewRouter() + + // Middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RealIP) + r.Use(middleware.RequestID) + + // Health check + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + // API v1 + r.Route("/v1", func(r chi.Router) { + // Auth routes + r.Route("/auth", func(r chi.Router) { + r.Post("/oauth/github", handlers.NotImplemented) + r.Get("/oauth/github/callback", handlers.NotImplemented) + r.Post("/oauth/google", handlers.NotImplemented) + r.Get("/oauth/google/callback", handlers.NotImplemented) + r.Post("/refresh", handlers.NotImplemented) + r.Post("/logout", handlers.NotImplemented) + r.Get("/me", handlers.NotImplemented) + }) + + // Developer apps + r.Route("/apps", func(r chi.Router) { + r.Get("/", handlers.NotImplemented) + r.Post("/", handlers.NotImplemented) + r.Get("/{appID}", handlers.NotImplemented) + r.Patch("/{appID}", handlers.NotImplemented) + r.Delete("/{appID}", handlers.NotImplemented) + + // Versions + r.Route("/{appID}/versions", func(r chi.Router) { + r.Get("/", handlers.NotImplemented) + r.Post("/", handlers.NotImplemented) + r.Get("/{versionID}", handlers.NotImplemented) + r.Post("/{versionID}/submit", handlers.NotImplemented) + r.Post("/{versionID}/publish", handlers.NotImplemented) + }) + }) + + // API Keys + r.Route("/api-keys", func(r chi.Router) { + r.Get("/", handlers.NotImplemented) + r.Post("/", handlers.NotImplemented) + r.Delete("/{keyID}", handlers.NotImplemented) + }) + + // Signing Keys + r.Route("/signing-keys", func(r chi.Router) { + r.Get("/", handlers.NotImplemented) + r.Post("/", handlers.NotImplemented) + r.Delete("/{keyID}", handlers.NotImplemented) + }) + + // Public store endpoints + r.Route("/store", func(r chi.Router) { + r.Get("/apps", handlers.NotImplemented) + r.Get("/apps/{appID}", handlers.NotImplemented) + r.Get("/apps/{appID}/download", handlers.NotImplemented) + r.Get("/apps/updates", handlers.NotImplemented) + }) + + // Telemetry + r.Route("/telemetry", func(r chi.Router) { + r.Post("/events", handlers.NotImplemented) + r.Post("/crash", handlers.NotImplemented) + }) + }) + + // Admin routes (htmx UI) + r.Route("/admin", func(r chi.Router) { + r.Get("/", handlers.NotImplemented) + r.Get("/review-queue", handlers.NotImplemented) + r.Get("/review/{versionID}", handlers.NotImplemented) + r.Post("/review/{versionID}/approve", handlers.NotImplemented) + r.Post("/review/{versionID}/reject", handlers.NotImplemented) + }) + + return r +} diff --git a/portal/internal/config/config.go b/portal/internal/config/config.go new file mode 100644 index 0000000..0fd1f66 --- /dev/null +++ b/portal/internal/config/config.go @@ -0,0 +1,60 @@ +// Package config handles configuration loading for mosis-portal +package config + +import ( + "os" +) + +// Config holds all configuration for the portal +type Config struct { + // Server settings + ListenAddr string + BaseURL string + + // Database + DatabasePath string + + // JWT + JWTSecret string + + // OAuth2 - GitHub + GitHubClientID string + GitHubClientSecret string + + // OAuth2 - Google + GoogleClientID string + GoogleClientSecret string + + // Storage + PackagesDir string + BackupsDir string +} + +// Load reads configuration from environment variables with defaults +func Load() (*Config, error) { + cfg := &Config{ + ListenAddr: getEnv("LISTEN_ADDR", ":8080"), + BaseURL: getEnv("BASE_URL", "http://localhost:8080"), + DatabasePath: getEnv("DATABASE_PATH", "./data/portal.db"), + + JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + + GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), + GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + + PackagesDir: getEnv("PACKAGES_DIR", "./packages"), + BackupsDir: getEnv("BACKUPS_DIR", "./backups"), + } + + return cfg, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/portal/internal/database/database.go b/portal/internal/database/database.go new file mode 100644 index 0000000..2dc430d --- /dev/null +++ b/portal/internal/database/database.go @@ -0,0 +1,203 @@ +// Package database handles SQLite database operations +package database + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// Open opens the SQLite database with WAL mode enabled +func Open(path string) (*sql.DB, error) { + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + + // Open database with WAL mode and busy timeout + dsn := fmt.Sprintf("%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Test connection + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + // Set connection pool settings for SQLite + db.SetMaxOpenConns(1) // SQLite single writer + db.SetMaxIdleConns(1) + + return db, nil +} + +// Migrate runs all database migrations +func Migrate(db *sql.DB) error { + migrations := []string{ + migrationDevelopers, + migrationAPIKeys, + migrationApps, + migrationAppVersions, + migrationSigningKeys, + migrationTelemetry, + migrationAuditLogs, + migrationIndexes, + } + + for i, migration := range migrations { + if _, err := db.Exec(migration); err != nil { + return fmt.Errorf("migration %d: %w", i+1, err) + } + } + + return nil +} + +const migrationDevelopers = ` +CREATE TABLE IF NOT EXISTS developers ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + password_hash TEXT, + oauth_provider TEXT, + oauth_id TEXT, + verified INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); +` + +const migrationAPIKeys = ` +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + developer_id TEXT NOT NULL REFERENCES developers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + permissions TEXT DEFAULT '[]', + last_used_at TEXT, + expires_at TEXT, + created_at TEXT DEFAULT (datetime('now')) +); +` + +const migrationApps = ` +CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + developer_id TEXT NOT NULL REFERENCES developers(id) ON DELETE CASCADE, + package_id TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + category TEXT, + tags TEXT DEFAULT '[]', + status TEXT DEFAULT 'draft', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); +` + +const migrationAppVersions = ` +CREATE TABLE IF NOT EXISTS app_versions ( + id TEXT PRIMARY KEY, + app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + version_code INTEGER NOT NULL, + version_name TEXT NOT NULL, + package_url TEXT NOT NULL, + package_size INTEGER NOT NULL, + signature TEXT NOT NULL, + permissions TEXT DEFAULT '[]', + min_mosis_version TEXT, + release_notes TEXT, + status TEXT DEFAULT 'draft', + review_notes TEXT, + published_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(app_id, version_code) +); +` + +const migrationSigningKeys = ` +CREATE TABLE IF NOT EXISTS signing_keys ( + id TEXT PRIMARY KEY, + developer_id TEXT NOT NULL REFERENCES developers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + public_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) +); +` + +const migrationTelemetry = ` +CREATE TABLE IF NOT EXISTS telemetry_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + app_id TEXT NOT NULL, + device_id TEXT NOT NULL, + event_type TEXT NOT NULL, + event_data TEXT, + mosis_version TEXT, + timestamp TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS crash_reports ( + id TEXT PRIMARY KEY, + app_id TEXT NOT NULL, + app_version TEXT NOT NULL, + device_id TEXT NOT NULL, + crash_type TEXT NOT NULL, + message TEXT, + stack_trace TEXT, + context TEXT, + mosis_version TEXT, + timestamp TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS telemetry_daily ( + app_id TEXT NOT NULL, + date TEXT NOT NULL, + event_type TEXT NOT NULL, + count INTEGER NOT NULL, + unique_devices INTEGER NOT NULL, + PRIMARY KEY (app_id, date, event_type) +); +` + +const migrationAuditLogs = ` +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + developer_id TEXT, + action TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + details TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TEXT DEFAULT (datetime('now')) +); +` + +const migrationIndexes = ` +CREATE INDEX IF NOT EXISTS idx_developers_email ON developers(email); +CREATE INDEX IF NOT EXISTS idx_developers_oauth ON developers(oauth_provider, oauth_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_developer ON api_keys(developer_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix); +CREATE INDEX IF NOT EXISTS idx_apps_developer ON apps(developer_id); +CREATE INDEX IF NOT EXISTS idx_apps_package ON apps(package_id); +CREATE INDEX IF NOT EXISTS idx_apps_status ON apps(status); +CREATE INDEX IF NOT EXISTS idx_versions_app ON app_versions(app_id); +CREATE INDEX IF NOT EXISTS idx_versions_status ON app_versions(status); +CREATE INDEX IF NOT EXISTS idx_signing_keys_developer ON signing_keys(developer_id); +CREATE INDEX IF NOT EXISTS idx_signing_keys_fingerprint ON signing_keys(fingerprint); +CREATE INDEX IF NOT EXISTS idx_telemetry_app ON telemetry_events(app_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_crashes_app ON crash_reports(app_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_developer ON audit_logs(developer_id); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); +` diff --git a/portal/litestream.yml b/portal/litestream.yml new file mode 100644 index 0000000..844c5b2 --- /dev/null +++ b/portal/litestream.yml @@ -0,0 +1,10 @@ +# Litestream configuration for mosis-portal +# Continuous backup of SQLite database to local storage + +dbs: + - path: /data/portal.db + replicas: + - type: file + path: /backups/portal + retention: 720h # 30 days + sync-interval: 1s diff --git a/portal/pkg/mospkg/manifest.go b/portal/pkg/mospkg/manifest.go new file mode 100644 index 0000000..92a30e5 --- /dev/null +++ b/portal/pkg/mospkg/manifest.go @@ -0,0 +1,185 @@ +// Package mospkg provides functionality for Mosis app packages (.mosis files) +package mospkg + +import ( + "encoding/json" + "fmt" + "regexp" +) + +// Manifest represents the app manifest (manifest.json) +type Manifest struct { + Schema string `json:"$schema,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + VersionCode int `json:"version_code"` + Entry string `json:"entry"` + MinMosisVersion string `json:"min_mosis_version"` + Description string `json:"description,omitempty"` + Author *Author `json:"author,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Icons Icons `json:"icons,omitempty"` + TargetMosisVer string `json:"target_mosis_version,omitempty"` + Category string `json:"category,omitempty"` + Tags []string `json:"tags,omitempty"` + Orientation string `json:"orientation,omitempty"` + BackgroundColor string `json:"background_color,omitempty"` + Locales []string `json:"locales,omitempty"` + DefaultLocale string `json:"default_locale,omitempty"` +} + +// Author represents the app author information +type Author struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + URL string `json:"url,omitempty"` +} + +// Icons represents app icon paths by size +type Icons struct { + Size32 string `json:"32,omitempty"` + Size64 string `json:"64,omitempty"` + Size128 string `json:"128,omitempty"` +} + +// ParseManifest parses a manifest.json from bytes +func ParseManifest(data []byte) (*Manifest, error) { + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + return &m, nil +} + +// Validate checks if the manifest contains all required fields with valid values +func (m *Manifest) Validate() []ValidationError { + var errors []ValidationError + + // Required fields + if m.ID == "" { + errors = append(errors, ValidationError{ + Code: "MISSING_ID", + Message: "Manifest is missing required field: id", + }) + } else if !isValidPackageID(m.ID) { + errors = append(errors, ValidationError{ + Code: "INVALID_ID", + Message: "Package ID must be in reverse domain format (e.g., com.developer.app)", + File: "manifest.json", + }) + } + + if m.Name == "" { + errors = append(errors, ValidationError{ + Code: "MISSING_NAME", + Message: "Manifest is missing required field: name", + }) + } else if len(m.Name) > 30 { + errors = append(errors, ValidationError{ + Code: "NAME_TOO_LONG", + Message: "App name must be 30 characters or less", + }) + } + + if m.Version == "" { + errors = append(errors, ValidationError{ + Code: "MISSING_VERSION", + Message: "Manifest is missing required field: version", + }) + } else if !isValidSemver(m.Version) { + errors = append(errors, ValidationError{ + Code: "INVALID_VERSION", + Message: "Version must be in semantic version format (X.Y.Z)", + }) + } + + if m.VersionCode <= 0 { + errors = append(errors, ValidationError{ + Code: "INVALID_VERSION_CODE", + Message: "version_code must be a positive integer", + }) + } + + if m.Entry == "" { + errors = append(errors, ValidationError{ + Code: "MISSING_ENTRY", + Message: "Manifest is missing required field: entry", + }) + } + + if m.MinMosisVersion == "" { + errors = append(errors, ValidationError{ + Code: "MISSING_MIN_MOSIS_VERSION", + Message: "Manifest is missing required field: min_mosis_version", + }) + } + + // Validate permissions + for _, perm := range m.Permissions { + if !isValidPermission(perm) { + errors = append(errors, ValidationError{ + Code: "INVALID_PERMISSION", + Message: fmt.Sprintf("Unknown permission: %s", perm), + }) + } + } + + // Validate orientation if specified + if m.Orientation != "" { + validOrientations := map[string]bool{"portrait": true, "landscape": true, "any": true} + if !validOrientations[m.Orientation] { + errors = append(errors, ValidationError{ + Code: "INVALID_ORIENTATION", + Message: "Orientation must be one of: portrait, landscape, any", + }) + } + } + + // Validate background color if specified + if m.BackgroundColor != "" && !isValidHexColor(m.BackgroundColor) { + errors = append(errors, ValidationError{ + Code: "INVALID_BACKGROUND_COLOR", + Message: "background_color must be a valid hex color (e.g., #FFFFFF)", + }) + } + + return errors +} + +// Package ID pattern: reverse domain notation +var packageIDPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$`) + +func isValidPackageID(id string) bool { + return packageIDPattern.MatchString(id) +} + +// Semver pattern: X.Y.Z +var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + +func isValidSemver(version string) bool { + return semverPattern.MatchString(version) +} + +// Hex color pattern: #RGB or #RRGGBB +var hexColorPattern = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$`) + +func isValidHexColor(color string) bool { + return hexColorPattern.MatchString(color) +} + +// Valid permissions +var validPermissions = map[string]bool{ + "storage": true, + "network": true, + "camera": true, + "microphone": true, + "location": true, + "contacts": true, + "calendar": true, + "sensors": true, +} + +func isValidPermission(perm string) bool { + return validPermissions[perm] +} diff --git a/portal/pkg/mospkg/signer.go b/portal/pkg/mospkg/signer.go new file mode 100644 index 0000000..5113977 --- /dev/null +++ b/portal/pkg/mospkg/signer.go @@ -0,0 +1,360 @@ +// Package mospkg provides functionality for Mosis app packages (.mosis files) +package mospkg + +import ( + "archive/zip" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "os" + "sort" + "strings" +) + +// KeyPair represents an Ed25519 signing keypair +type KeyPair struct { + PrivateKey ed25519.PrivateKey + PublicKey ed25519.PublicKey +} + +// GenerateKeyPair generates a new Ed25519 keypair +func GenerateKeyPair() (*KeyPair, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + return &KeyPair{ + PrivateKey: privateKey, + PublicKey: publicKey, + }, nil +} + +// Fingerprint returns the SHA256 fingerprint of the public key +func (kp *KeyPair) Fingerprint() string { + hash := sha256.Sum256(kp.PublicKey) + return fmt.Sprintf("SHA256:%s", base64.StdEncoding.EncodeToString(hash[:])) +} + +// PrivateKeyPEM returns the private key in PEM format +func (kp *KeyPair) PrivateKeyPEM() ([]byte, error) { + pkcs8, err := x509.MarshalPKCS8PrivateKey(kp.PrivateKey) + if err != nil { + return nil, fmt.Errorf("marshal private key: %w", err) + } + return pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: pkcs8, + }), nil +} + +// PublicKeyPEM returns the public key in PEM format +func (kp *KeyPair) PublicKeyPEM() ([]byte, error) { + pkix, err := x509.MarshalPKIXPublicKey(kp.PublicKey) + if err != nil { + return nil, fmt.Errorf("marshal public key: %w", err) + } + return pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pkix, + }), nil +} + +// LoadPrivateKey loads an Ed25519 private key from PEM data +func LoadPrivateKey(pemData []byte) (ed25519.PrivateKey, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + ed25519Key, ok := key.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("key is not Ed25519") + } + + return ed25519Key, nil +} + +// LoadPublicKey loads an Ed25519 public key from PEM data +func LoadPublicKey(pemData []byte) (ed25519.PublicKey, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse public key: %w", err) + } + + ed25519Key, ok := key.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("key is not Ed25519") + } + + return ed25519Key, nil +} + +// PublicKeyFingerprint returns the SHA256 fingerprint of a public key +func PublicKeyFingerprint(publicKey ed25519.PublicKey) string { + hash := sha256.Sum256(publicKey) + return fmt.Sprintf("SHA256:%s", base64.StdEncoding.EncodeToString(hash[:])) +} + +// GenerateManifestMF generates MANIFEST.MF content for a package +func GenerateManifestMF(packagePath string) ([]byte, error) { + reader, err := zip.OpenReader(packagePath) + if err != nil { + return nil, fmt.Errorf("open package: %w", err) + } + defer reader.Close() + + var lines []string + lines = append(lines, "Manifest-Version: 1.0") + lines = append(lines, "Created-By: mosis-portal") + lines = append(lines, "") + + // Sort files for consistent ordering + var fileNames []string + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + // Skip META-INF files + if strings.HasPrefix(file.Name, "META-INF/") { + continue + } + fileNames = append(fileNames, file.Name) + } + sort.Strings(fileNames) + + // Generate hash for each file + for _, name := range fileNames { + for _, file := range reader.File { + if file.Name != name { + continue + } + + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("open file %s: %w", name, err) + } + + hasher := sha256.New() + if _, err := io.Copy(hasher, rc); err != nil { + rc.Close() + return nil, fmt.Errorf("hash file %s: %w", name, err) + } + rc.Close() + + digest := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + lines = append(lines, fmt.Sprintf("Name: %s", name)) + lines = append(lines, fmt.Sprintf("SHA-256-Digest: %s", digest)) + lines = append(lines, "") + } + } + + return []byte(strings.Join(lines, "\n")), nil +} + +// SignManifest signs MANIFEST.MF content with an Ed25519 private key +func SignManifest(manifestMF []byte, privateKey ed25519.PrivateKey) []byte { + return ed25519.Sign(privateKey, manifestMF) +} + +// VerifySignature verifies a signature against MANIFEST.MF using a public key +func VerifySignature(manifestMF, signature []byte, publicKey ed25519.PublicKey) bool { + return ed25519.Verify(publicKey, manifestMF, signature) +} + +// SignPackage signs a .mosis package by adding META-INF/MANIFEST.MF and META-INF/CERT.SIG +func SignPackage(packagePath, outputPath string, privateKey ed25519.PrivateKey) error { + // Generate MANIFEST.MF + manifestMF, err := GenerateManifestMF(packagePath) + if err != nil { + return fmt.Errorf("generate manifest: %w", err) + } + + // Sign manifest + signature := SignManifest(manifestMF, privateKey) + + // Open source package + srcReader, err := zip.OpenReader(packagePath) + if err != nil { + return fmt.Errorf("open source package: %w", err) + } + defer srcReader.Close() + + // Create output package + outFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create output: %w", err) + } + defer outFile.Close() + + writer := zip.NewWriter(outFile) + defer writer.Close() + + // Copy existing files (except META-INF) + for _, file := range srcReader.File { + if strings.HasPrefix(file.Name, "META-INF/") { + continue + } + + // Copy file to new archive + destFile, err := writer.CreateHeader(&file.FileHeader) + if err != nil { + return fmt.Errorf("create header: %w", err) + } + + srcFile, err := file.Open() + if err != nil { + return fmt.Errorf("open source file: %w", err) + } + + if _, err := io.Copy(destFile, srcFile); err != nil { + srcFile.Close() + return fmt.Errorf("copy file: %w", err) + } + srcFile.Close() + } + + // Add META-INF/MANIFEST.MF + manifestWriter, err := writer.Create("META-INF/MANIFEST.MF") + if err != nil { + return fmt.Errorf("create MANIFEST.MF: %w", err) + } + if _, err := manifestWriter.Write(manifestMF); err != nil { + return fmt.Errorf("write MANIFEST.MF: %w", err) + } + + // Add META-INF/CERT.SIG (base64 encoded signature) + sigWriter, err := writer.Create("META-INF/CERT.SIG") + if err != nil { + return fmt.Errorf("create CERT.SIG: %w", err) + } + if _, err := sigWriter.Write([]byte(base64.StdEncoding.EncodeToString(signature))); err != nil { + return fmt.Errorf("write CERT.SIG: %w", err) + } + + return nil +} + +// VerifyPackageSignature verifies the signature of a signed .mosis package +func VerifyPackageSignature(packagePath string, publicKey ed25519.PublicKey) (bool, error) { + reader, err := zip.OpenReader(packagePath) + if err != nil { + return false, fmt.Errorf("open package: %w", err) + } + defer reader.Close() + + // Find MANIFEST.MF and CERT.SIG + var manifestMF []byte + var signature []byte + + for _, file := range reader.File { + switch file.Name { + case "META-INF/MANIFEST.MF": + rc, err := file.Open() + if err != nil { + return false, fmt.Errorf("open MANIFEST.MF: %w", err) + } + manifestMF, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return false, fmt.Errorf("read MANIFEST.MF: %w", err) + } + + case "META-INF/CERT.SIG": + rc, err := file.Open() + if err != nil { + return false, fmt.Errorf("open CERT.SIG: %w", err) + } + sigB64, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return false, fmt.Errorf("read CERT.SIG: %w", err) + } + signature, err = base64.StdEncoding.DecodeString(string(sigB64)) + if err != nil { + return false, fmt.Errorf("decode signature: %w", err) + } + } + } + + if manifestMF == nil { + return false, fmt.Errorf("MANIFEST.MF not found") + } + if signature == nil { + return false, fmt.Errorf("CERT.SIG not found") + } + + // Verify signature + if !VerifySignature(manifestMF, signature, publicKey) { + return false, nil + } + + // Verify file hashes + hashes, err := parseManifestMF(manifestMF) + if err != nil { + return false, fmt.Errorf("parse MANIFEST.MF: %w", err) + } + + for _, file := range reader.File { + if file.FileInfo().IsDir() || strings.HasPrefix(file.Name, "META-INF/") { + continue + } + + expectedHash, ok := hashes[file.Name] + if !ok { + return false, fmt.Errorf("file not in manifest: %s", file.Name) + } + + rc, err := file.Open() + if err != nil { + return false, fmt.Errorf("open file %s: %w", file.Name, err) + } + + hasher := sha256.New() + if _, err := io.Copy(hasher, rc); err != nil { + rc.Close() + return false, fmt.Errorf("hash file %s: %w", file.Name, err) + } + rc.Close() + + actualHash := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + if actualHash != expectedHash { + return false, fmt.Errorf("hash mismatch for %s", file.Name) + } + } + + return true, nil +} + +// parseManifestMF parses MANIFEST.MF content and returns a map of filename -> SHA256 hash +func parseManifestMF(data []byte) (map[string]string, error) { + hashes := make(map[string]string) + lines := strings.Split(string(data), "\n") + + var currentFile string + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Name: ") { + currentFile = strings.TrimPrefix(line, "Name: ") + } else if strings.HasPrefix(line, "SHA-256-Digest: ") && currentFile != "" { + hashes[currentFile] = strings.TrimPrefix(line, "SHA-256-Digest: ") + currentFile = "" + } + } + + return hashes, nil +} diff --git a/portal/pkg/mospkg/validator.go b/portal/pkg/mospkg/validator.go new file mode 100644 index 0000000..0d627a2 --- /dev/null +++ b/portal/pkg/mospkg/validator.go @@ -0,0 +1,308 @@ +// Package mospkg provides functionality for Mosis app packages (.mosis files) +package mospkg + +import ( + "archive/zip" + "fmt" + "io" + "path/filepath" + "strings" +) + +// Size limits +const ( + MaxPackageSize = 50 * 1024 * 1024 // 50 MB + MaxFileSize = 10 * 1024 * 1024 // 10 MB + MaxFileCount = 1000 + MaxPathLength = 256 + MaxManifestSize = 64 * 1024 // 64 KB +) + +// ValidationError represents a validation error +type ValidationError struct { + Code string `json:"code"` + Message string `json:"message"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` +} + +// ValidationWarning represents a non-blocking validation warning +type ValidationWarning struct { + Code string `json:"code"` + Message string `json:"message"` + File string `json:"file,omitempty"` +} + +// ValidationResult holds the result of package validation +type ValidationResult struct { + Valid bool `json:"valid"` + Errors []ValidationError `json:"errors,omitempty"` + Warnings []ValidationWarning `json:"warnings,omitempty"` + Manifest *Manifest `json:"manifest,omitempty"` +} + +// AllowedExtensions are file extensions permitted in packages +var AllowedExtensions = map[string]bool{ + ".rml": true, + ".rcss": true, + ".lua": true, + ".png": true, + ".jpg": true, + ".jpeg": true, + ".tga": true, + ".webp": true, + ".ttf": true, + ".otf": true, + ".json": true, + ".ogg": true, + ".wav": true, + ".mp3": true, +} + +// ForbiddenExtensions are file extensions not allowed in packages +var ForbiddenExtensions = map[string]bool{ + ".exe": true, + ".dll": true, + ".so": true, + ".dylib": true, + ".sh": true, + ".bat": true, + ".ps1": true, + ".py": true, + ".js": true, + ".zip": true, + ".tar": true, + ".gz": true, +} + +// ValidatePackage validates a .mosis package file +func ValidatePackage(path string) (*ValidationResult, error) { + result := &ValidationResult{Valid: true} + + // Open ZIP archive + reader, err := zip.OpenReader(path) + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "INVALID_ZIP", + Message: fmt.Sprintf("Failed to open package: %v", err), + }) + return result, nil + } + defer reader.Close() + + // Check file count + if len(reader.File) > MaxFileCount { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "TOO_MANY_FILES", + Message: fmt.Sprintf("Package contains %d files, maximum is %d", len(reader.File), MaxFileCount), + }) + return result, nil + } + + var manifestData []byte + seenFiles := make(map[string]bool) + var totalSize int64 + + for _, file := range reader.File { + name := file.Name + + // Check for path traversal + if strings.Contains(name, "..") { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "PATH_TRAVERSAL", + Message: "Path traversal detected", + File: name, + }) + continue + } + + // Check for absolute paths + if filepath.IsAbs(name) { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "ABSOLUTE_PATH", + Message: "Absolute path not allowed", + File: name, + }) + continue + } + + // Check path length + if len(name) > MaxPathLength { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "PATH_TOO_LONG", + Message: fmt.Sprintf("Path exceeds %d characters", MaxPathLength), + File: name, + }) + continue + } + + // Check for duplicates + normalizedName := strings.ToLower(name) + if seenFiles[normalizedName] { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "DUPLICATE_FILE", + Message: "Duplicate file detected", + File: name, + }) + continue + } + seenFiles[normalizedName] = true + + // Skip directories + if file.FileInfo().IsDir() { + continue + } + + // Check individual file size + if file.UncompressedSize64 > uint64(MaxFileSize) { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "FILE_TOO_LARGE", + Message: fmt.Sprintf("File exceeds %d MB limit", MaxFileSize/(1024*1024)), + File: name, + }) + continue + } + + totalSize += int64(file.UncompressedSize64) + + // Check file extension + ext := strings.ToLower(filepath.Ext(name)) + if ForbiddenExtensions[ext] { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "FORBIDDEN_EXTENSION", + Message: fmt.Sprintf("File type %s is not allowed", ext), + File: name, + }) + continue + } + + // Skip META-INF files for extension check + if !strings.HasPrefix(name, "META-INF/") && ext != "" && !AllowedExtensions[ext] { + result.Warnings = append(result.Warnings, ValidationWarning{ + Code: "UNKNOWN_EXTENSION", + Message: fmt.Sprintf("Unknown file extension: %s", ext), + File: name, + }) + } + + // Read manifest.json + if name == "manifest.json" { + rc, err := file.Open() + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "MANIFEST_READ_ERROR", + Message: fmt.Sprintf("Failed to read manifest: %v", err), + }) + continue + } + manifestData, err = io.ReadAll(io.LimitReader(rc, MaxManifestSize)) + rc.Close() + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "MANIFEST_READ_ERROR", + Message: fmt.Sprintf("Failed to read manifest: %v", err), + }) + } + } + } + + // Check total size + if totalSize > MaxPackageSize { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "PACKAGE_TOO_LARGE", + Message: fmt.Sprintf("Package exceeds %d MB limit", MaxPackageSize/(1024*1024)), + }) + } + + // Validate manifest + if manifestData == nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "MISSING_MANIFEST", + Message: "Package is missing manifest.json", + }) + } else { + manifest, err := ParseManifest(manifestData) + if err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "INVALID_MANIFEST", + Message: fmt.Sprintf("Invalid manifest.json: %v", err), + }) + } else { + result.Manifest = manifest + manifestErrors := manifest.Validate() + if len(manifestErrors) > 0 { + result.Valid = false + result.Errors = append(result.Errors, manifestErrors...) + } + + // Check that entry point exists + entryExists := false + for _, file := range reader.File { + if file.Name == manifest.Entry { + entryExists = true + break + } + } + if !entryExists { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Code: "MISSING_ENTRY", + Message: fmt.Sprintf("Entry point file not found: %s", manifest.Entry), + }) + } + + // Check icons + if result.Manifest.Icons.Size32 != "" { + if !fileExistsInZip(reader, result.Manifest.Icons.Size32) { + result.Errors = append(result.Errors, ValidationError{ + Code: "MISSING_ICON", + Message: "Icon file not found", + File: result.Manifest.Icons.Size32, + }) + } + } + if result.Manifest.Icons.Size64 != "" { + if !fileExistsInZip(reader, result.Manifest.Icons.Size64) { + result.Errors = append(result.Errors, ValidationError{ + Code: "MISSING_ICON", + Message: "Icon file not found", + File: result.Manifest.Icons.Size64, + }) + } + } + if result.Manifest.Icons.Size128 != "" { + if !fileExistsInZip(reader, result.Manifest.Icons.Size128) { + result.Errors = append(result.Errors, ValidationError{ + Code: "MISSING_ICON", + Message: "Icon file not found", + File: result.Manifest.Icons.Size128, + }) + } + } + } + } + + return result, nil +} + +func fileExistsInZip(reader *zip.ReadCloser, name string) bool { + for _, file := range reader.File { + if file.Name == name { + return true + } + } + return false +}