add mosis-portal Go project with package signing and validation
This commit is contained in:
50
portal/Dockerfile
Normal file
50
portal/Dockerfile
Normal 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
194
portal/README.md
Normal 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
73
portal/cmd/server/main.go
Normal 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
31
portal/docker-compose.yml
Normal 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
12
portal/go.mod
Normal 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
|
||||
)
|
||||
35
portal/internal/api/handlers/handlers.go
Normal file
35
portal/internal/api/handlers/handlers.go
Normal 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})
|
||||
}
|
||||
100
portal/internal/api/router.go
Normal file
100
portal/internal/api/router.go
Normal 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
|
||||
}
|
||||
60
portal/internal/config/config.go
Normal file
60
portal/internal/config/config.go
Normal 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
|
||||
}
|
||||
203
portal/internal/database/database.go
Normal file
203
portal/internal/database/database.go
Normal 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
10
portal/litestream.yml
Normal 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
|
||||
185
portal/pkg/mospkg/manifest.go
Normal file
185
portal/pkg/mospkg/manifest.go
Normal 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
360
portal/pkg/mospkg/signer.go
Normal 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
|
||||
}
|
||||
308
portal/pkg/mospkg/validator.go
Normal file
308
portal/pkg/mospkg/validator.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user