add mosis-portal Go project with package signing and validation

This commit is contained in:
2026-01-18 20:56:06 +01:00
parent d76627ebc3
commit 2eb6292dc2
14 changed files with 1671 additions and 10 deletions

View File

@@ -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
---

50
portal/Dockerfile Normal file
View File

@@ -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"]

194
portal/README.md Normal file
View File

@@ -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

73
portal/cmd/server/main.go Normal file
View File

@@ -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")
}

31
portal/docker-compose.yml Normal file
View File

@@ -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

12
portal/go.mod Normal file
View File

@@ -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
)

View File

@@ -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})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);
`

10
portal/litestream.yml Normal file
View File

@@ -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

View File

@@ -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]
}

360
portal/pkg/mospkg/signer.go Normal file
View File

@@ -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
}

View File

@@ -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
}