feat: basic firmware registry

This commit is contained in:
2025-10-21 14:27:21 +02:00
commit 986745d8c8
20 changed files with 1831 additions and 0 deletions

109
internal/app/app.go Normal file
View File

@@ -0,0 +1,109 @@
package app
import (
"fmt"
"log"
"net/http"
"os"
"github.com/rs/cors"
"spore-registry/internal/config"
"spore-registry/internal/database"
"spore-registry/internal/handlers"
"spore-registry/internal/repository"
"spore-registry/internal/service"
"spore-registry/internal/storage"
)
// App holds the application dependencies
type App struct {
config *config.Config
db *database.DB
handler *handlers.FirmwareHandler
}
// NewApp creates a new application instance
func NewApp() (*App, error) {
// Load configuration
cfg := config.LoadConfig()
// Create registry directory if it doesn't exist
if err := os.MkdirAll(cfg.Registry, 0755); err != nil {
return nil, fmt.Errorf("failed to create registry directory: %w", err)
}
// Initialize database
db, err := database.NewDB(cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Create repository
repo := repository.NewFirmwareRepository(db.GetConnection())
// Create storage
fileStorage := storage.NewFileStorage(cfg.Registry)
// Create service
firmwareService := service.NewFirmwareService(repo, fileStorage)
// Create handler
handler := handlers.NewFirmwareHandler(firmwareService)
return &App{
config: cfg,
db: db,
handler: handler,
}, nil
}
// Close closes the database connection
func (a *App) Close() error {
return a.db.Close()
}
// SetupRoutes sets up HTTP routes
func (a *App) SetupRoutes() *http.ServeMux {
mux := http.NewServeMux()
// API endpoints
mux.HandleFunc("POST /firmware", a.handler.UploadFirmware)
mux.HandleFunc("PUT /firmware/{name}/{version}", a.handler.UpdateFirmwareMetadata)
mux.HandleFunc("GET /firmware", a.handler.ListFirmware)
mux.HandleFunc("GET /firmware/{name}/{version}", a.handler.DownloadFirmware)
// Health check endpoint
mux.HandleFunc("GET /health", a.handler.HealthCheck)
return mux
}
// Start starts the HTTP server
func (a *App) Start() error {
mux := a.SetupRoutes()
log.Printf("Starting SPORE registry server on port %s", a.config.Port)
log.Printf("Server will be accessible from any host on port %s", a.config.Port)
log.Printf("CORS enabled for all origins to support mobile access")
// Add CORS middleware with dynamic origin handling
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"}, // Allow all origins for mobile access
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
AllowCredentials: false, // Set to false when using wildcard origins
AllowOriginFunc: func(origin string) bool {
// Allow all origins for mobile/remote access
return true
},
})
// Add request logging middleware
loggedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
c.Handler(mux).ServeHTTP(w, r)
})
return http.ListenAndServe(":"+a.config.Port, loggedHandler)
}

47
internal/config/config.go Normal file
View File

@@ -0,0 +1,47 @@
package config
import (
"os"
"strconv"
)
// Config holds the application configuration
type Config struct {
Port string
DBPath string
Registry string
MaxSize int64
}
// LoadConfig loads configuration from environment variables with defaults
func LoadConfig() *Config {
port := os.Getenv("PORT")
if port == "" {
port = "3002"
}
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./registry.db"
}
registry := os.Getenv("REGISTRY_PATH")
if registry == "" {
registry = "registry"
}
maxSizeStr := os.Getenv("MAX_UPLOAD_SIZE")
maxSize := int64(32 << 20) // 32MB default
if maxSizeStr != "" {
if parsed, err := strconv.ParseInt(maxSizeStr, 10, 64); err == nil {
maxSize = parsed
}
}
return &Config{
Port: port,
DBPath: dbPath,
Registry: registry,
MaxSize: maxSize,
}
}

View File

@@ -0,0 +1,61 @@
package database
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
// DB wraps the database connection and provides methods for database operations
type DB struct {
conn *sql.DB
}
// NewDB creates a new database connection
func NewDB(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db := &DB{conn: conn}
// Create tables
if err := db.createTables(); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return db, nil
}
// Close closes the database connection
func (db *DB) Close() error {
return db.conn.Close()
}
// GetConnection returns the underlying database connection
func (db *DB) GetConnection() *sql.DB {
return db.conn
}
// createTables creates the necessary database tables
func (db *DB) createTables() error {
query := `
CREATE TABLE IF NOT EXISTS firmware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
version TEXT NOT NULL,
size INTEGER NOT NULL,
labels TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, version)
);`
if _, err := db.conn.Exec(query); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,207 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strings"
"spore-registry/internal/models"
"spore-registry/internal/service"
)
// FirmwareHandler handles HTTP requests for firmware operations
type FirmwareHandler struct {
service *service.FirmwareService
}
// NewFirmwareHandler creates a new firmware handler
func NewFirmwareHandler(service *service.FirmwareService) *FirmwareHandler {
return &FirmwareHandler{service: service}
}
// UploadFirmware handles POST /firmware endpoint
func (h *FirmwareHandler) UploadFirmware(w http.ResponseWriter, r *http.Request) {
log.Printf("POST /firmware - %s", r.RemoteAddr)
if r.Method != http.MethodPost {
log.Printf("Invalid method %s for /firmware", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse multipart form
err := r.ParseMultipartForm(32 << 20) // 32MB max
if err != nil {
log.Printf("Failed to parse multipart form: %v", err)
http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
return
}
// Get metadata from form
metadataJSON := r.FormValue("metadata")
if metadataJSON == "" {
log.Printf("Missing metadata field in firmware upload")
http.Error(w, "Missing metadata field", http.StatusBadRequest)
return
}
var metadata models.FirmwareMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
log.Printf("Invalid metadata JSON: %v", err)
http.Error(w, "Invalid metadata JSON: "+err.Error(), http.StatusBadRequest)
return
}
// Get firmware file
file, _, err := r.FormFile("firmware")
if err != nil {
log.Printf("Missing firmware file in upload: %v", err)
http.Error(w, "Missing firmware file: "+err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
log.Printf("Uploading firmware: %s/%s", metadata.Name, metadata.Version)
// Upload firmware using service
response, err := h.service.UploadFirmware(metadata, file)
if err != nil {
log.Printf("Failed to upload firmware: %v", err)
http.Error(w, "Failed to upload firmware: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Successfully uploaded firmware: %s/%s (size: %d bytes)", metadata.Name, metadata.Version, response.Size)
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// ListFirmware handles GET /firmware endpoint with optional query parameters
func (h *FirmwareHandler) ListFirmware(w http.ResponseWriter, r *http.Request) {
log.Printf("GET /firmware - %s (name=%s, version=%s)",
r.RemoteAddr, r.URL.Query().Get("name"), r.URL.Query().Get("version"))
if r.Method != http.MethodGet {
log.Printf("Invalid method %s for /firmware", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse query parameters
name := r.URL.Query().Get("name")
version := r.URL.Query().Get("version")
// Get firmware records using service
grouped, err := h.service.ListFirmware(name, version)
if err != nil {
log.Printf("Failed to retrieve firmware list: %v", err)
http.Error(w, "Failed to retrieve firmware list: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("Returning %d firmware groups", len(grouped))
// Return grouped firmware list
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(grouped)
}
// UpdateFirmwareMetadata handles PUT /firmware/{name}/{version} endpoint for metadata-only updates
func (h *FirmwareHandler) UpdateFirmwareMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract path parameters
path := strings.TrimPrefix(r.URL.Path, "/firmware/")
parts := strings.Split(path, "/")
if len(parts) != 2 {
log.Printf("Invalid firmware path for update: %s", r.URL.Path)
http.Error(w, "Invalid firmware path", http.StatusBadRequest)
return
}
name := parts[0]
version := parts[1]
log.Printf("PUT /firmware/%s/%s - %s", name, version, r.RemoteAddr)
// Parse JSON body
var metadata models.FirmwareMetadata
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
log.Printf("Invalid JSON in metadata update: %v", err)
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
// Update metadata using service
response, err := h.service.UpdateFirmwareMetadata(name, version, metadata)
if err != nil {
if strings.Contains(err.Error(), "not found") {
log.Printf("Firmware not found for update: %s/%s", name, version)
http.Error(w, "Firmware not found", http.StatusNotFound)
} else {
log.Printf("Failed to update firmware metadata: %v", err)
http.Error(w, "Failed to update firmware metadata: "+err.Error(), http.StatusInternalServerError)
}
return
}
log.Printf("Successfully updated firmware metadata: %s/%s", name, version)
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
// DownloadFirmware handles GET /firmware/{name}/{version} endpoint
func (h *FirmwareHandler) DownloadFirmware(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract path parameters
path := strings.TrimPrefix(r.URL.Path, "/firmware/")
parts := strings.Split(path, "/")
if len(parts) != 2 {
log.Printf("Invalid firmware path: %s", r.URL.Path)
http.Error(w, "Invalid firmware path", http.StatusBadRequest)
return
}
name := parts[0]
version := parts[1]
log.Printf("GET /firmware/%s/%s - %s", name, version, r.RemoteAddr)
// Get firmware file path using service
filePath, err := h.service.GetFirmwarePath(name, version)
if err != nil {
if strings.Contains(err.Error(), "not found") {
log.Printf("Firmware not found: %s/%s", name, version)
http.Error(w, "Firmware not found", http.StatusNotFound)
} else {
log.Printf("Failed to get firmware path: %v", err)
http.Error(w, "Failed to get firmware: "+err.Error(), http.StatusInternalServerError)
}
return
}
// Serve the file
log.Printf("Serving firmware file: %s", filePath)
http.ServeFile(w, r, filePath)
}
// HealthCheck provides a simple health check endpoint
func (h *FirmwareHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
response := models.HealthResponse{Status: "healthy"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,37 @@
package models
// FirmwareMetadata represents the metadata structure for firmware uploads
type FirmwareMetadata struct {
Name string `json:"name"`
Version string `json:"version"`
Labels map[string]string `json:"labels"`
}
// FirmwareRecord represents a firmware record in the database
type FirmwareRecord struct {
Name string `json:"name"`
Version string `json:"version"`
Size int64 `json:"size"`
Labels map[string]string `json:"labels"`
Path string `json:"download_url"`
}
// GroupedFirmware represents firmware grouped by name
type GroupedFirmware struct {
Name string `json:"name"`
Firmware []FirmwareRecord `json:"firmware"`
}
// UploadResponse represents the response for firmware upload
type UploadResponse struct {
Success bool `json:"success"`
Name string `json:"name"`
Version string `json:"version"`
Size int64 `json:"size"`
Message string `json:"message,omitempty"`
}
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
}

View File

@@ -0,0 +1,82 @@
package repository
import (
"database/sql"
"encoding/json"
"fmt"
"spore-registry/internal/models"
)
// FirmwareRepository handles database operations for firmware
type FirmwareRepository struct {
db *sql.DB
}
// NewFirmwareRepository creates a new firmware repository
func NewFirmwareRepository(db *sql.DB) *FirmwareRepository {
return &FirmwareRepository{db: db}
}
// StoreFirmwareMetadata stores firmware metadata in the database
func (r *FirmwareRepository) StoreFirmwareMetadata(metadata models.FirmwareMetadata, size int64) error {
labelsJSON, err := json.Marshal(metadata.Labels)
if err != nil {
return fmt.Errorf("failed to marshal labels: %w", err)
}
query := `INSERT OR REPLACE INTO firmware (name, version, size, labels) VALUES (?, ?, ?, ?)`
_, err = r.db.Exec(query, metadata.Name, metadata.Version, size, string(labelsJSON))
if err != nil {
return fmt.Errorf("failed to store firmware metadata: %w", err)
}
return nil
}
// GetFirmwareRecords retrieves firmware records with optional filtering
func (r *FirmwareRepository) GetFirmwareRecords(name, version string) ([]models.FirmwareRecord, error) {
var records []models.FirmwareRecord
// Build query with optional filters
query := `
SELECT name, version, size, labels
FROM firmware
WHERE 1=1`
var args []interface{}
if name != "" {
query += " AND name = ?"
args = append(args, name)
}
if version != "" {
query += " AND version = ?"
args = append(args, version)
}
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query firmware records: %w", err)
}
defer rows.Close()
for rows.Next() {
var record models.FirmwareRecord
var labelsJSON string
err := rows.Scan(&record.Name, &record.Version, &record.Size, &labelsJSON)
if err != nil {
return nil, fmt.Errorf("failed to scan firmware record: %w", err)
}
if err := json.Unmarshal([]byte(labelsJSON), &record.Labels); err != nil {
return nil, fmt.Errorf("failed to unmarshal labels: %w", err)
}
// Construct download path
record.Path = fmt.Sprintf("/firmware/%s/%s", record.Name, record.Version)
records = append(records, record)
}
return records, nil
}

View File

@@ -0,0 +1,190 @@
package service
import (
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"spore-registry/internal/models"
"spore-registry/internal/repository"
"spore-registry/internal/storage"
)
// FirmwareService handles business logic for firmware operations
type FirmwareService struct {
repo *repository.FirmwareRepository
storage *storage.FileStorage
}
// NewFirmwareService creates a new firmware service
func NewFirmwareService(repo *repository.FirmwareRepository, storage *storage.FileStorage) *FirmwareService {
return &FirmwareService{
repo: repo,
storage: storage,
}
}
// UploadFirmware uploads firmware binary and metadata
func (s *FirmwareService) UploadFirmware(metadata models.FirmwareMetadata, data io.Reader) (*models.UploadResponse, error) {
// Validate required fields
if metadata.Name == "" || metadata.Version == "" {
return nil, fmt.Errorf("name and version are required")
}
// Store firmware binary
filePath, size, err := s.storage.StoreFirmwareBinary(metadata.Name, metadata.Version, data)
if err != nil {
return nil, fmt.Errorf("failed to store firmware binary: %w", err)
}
// Store metadata in database
if err := s.repo.StoreFirmwareMetadata(metadata, size); err != nil {
// Try to clean up the file if database operation fails
os.Remove(filePath)
return nil, fmt.Errorf("failed to store firmware metadata: %w", err)
}
return &models.UploadResponse{
Success: true,
Name: metadata.Name,
Version: metadata.Version,
Size: size,
}, nil
}
// ListFirmware retrieves firmware records with optional filtering
func (s *FirmwareService) ListFirmware(name, version string) ([]models.GroupedFirmware, error) {
records, err := s.repo.GetFirmwareRecords(name, version)
if err != nil {
return nil, fmt.Errorf("failed to retrieve firmware records: %w", err)
}
grouped := s.groupFirmwareByName(records)
return grouped, nil
}
// UpdateFirmwareMetadata updates firmware metadata
func (s *FirmwareService) UpdateFirmwareMetadata(name, version string, metadata models.FirmwareMetadata) (*models.UploadResponse, error) {
// Validate that the name and version match the URL
if metadata.Name != name || metadata.Version != version {
return nil, fmt.Errorf("name and version in URL must match metadata")
}
// Check if firmware exists
records, err := s.repo.GetFirmwareRecords(name, version)
if err != nil {
return nil, fmt.Errorf("failed to check if firmware exists: %w", err)
}
if len(records) == 0 {
return nil, fmt.Errorf("firmware not found")
}
// Get existing firmware size (keep the same size since we're not updating the binary)
existingRecord := records[0]
size := existingRecord.Size
// Update metadata in database
if err := s.repo.StoreFirmwareMetadata(metadata, size); err != nil {
return nil, fmt.Errorf("failed to update firmware metadata: %w", err)
}
return &models.UploadResponse{
Success: true,
Name: metadata.Name,
Version: metadata.Version,
Size: size,
Message: "Metadata updated successfully",
}, nil
}
// GetFirmwarePath returns the file path for downloading firmware
func (s *FirmwareService) GetFirmwarePath(name, version string) (string, error) {
if !s.storage.FirmwareExists(name, version) {
return "", fmt.Errorf("firmware not found")
}
return s.storage.GetFirmwareBinaryPath(name, version), nil
}
// groupFirmwareByName groups firmware records by name
func (s *FirmwareService) groupFirmwareByName(records []models.FirmwareRecord) []models.GroupedFirmware {
nameMap := make(map[string][]models.FirmwareRecord)
// Group records by name
for _, record := range records {
nameMap[record.Name] = append(nameMap[record.Name], record)
}
// Convert to slice
var grouped []models.GroupedFirmware
for name, firmware := range nameMap {
// Sort firmware versions by version number (highest first)
sort.Slice(firmware, func(i, j int) bool {
return compareVersions(firmware[i].Version, firmware[j].Version) > 0
})
grouped = append(grouped, models.GroupedFirmware{
Name: name,
Firmware: firmware,
})
}
// Sort firmware groups by name (A to Z)
sort.Slice(grouped, func(i, j int) bool {
return grouped[i].Name < grouped[j].Name
})
return grouped
}
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
// Remove 'v' prefix if present
v1 = strings.TrimPrefix(v1, "v")
v2 = strings.TrimPrefix(v2, "v")
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
// Pad shorter version with zeros
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for len(parts1) < maxLen {
parts1 = append(parts1, "0")
}
for len(parts2) < maxLen {
parts2 = append(parts2, "0")
}
// Compare each part
for i := 0; i < maxLen; i++ {
num1, err1 := strconv.Atoi(parts1[i])
num2, err2 := strconv.Atoi(parts2[i])
// If parsing fails, do string comparison
if err1 != nil || err2 != nil {
if parts1[i] < parts2[i] {
return -1
} else if parts1[i] > parts2[i] {
return 1
}
continue
}
if num1 < num2 {
return -1
} else if num1 > num2 {
return 1
}
}
return 0
}

57
internal/storage/file.go Normal file
View File

@@ -0,0 +1,57 @@
package storage
import (
"fmt"
"io"
"os"
"path/filepath"
)
// FileStorage handles file system operations for firmware binaries
type FileStorage struct {
registryPath string
}
// NewFileStorage creates a new file storage instance
func NewFileStorage(registryPath string) *FileStorage {
return &FileStorage{registryPath: registryPath}
}
// StoreFirmwareBinary stores a firmware binary in the hierarchical file structure
func (fs *FileStorage) StoreFirmwareBinary(name, version string, data io.Reader) (string, int64, error) {
// Create directory path: registry/<name>/<version>/
dirPath := filepath.Join(fs.registryPath, name, version)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return "", 0, fmt.Errorf("failed to create firmware directory: %w", err)
}
// Create firmware file path
filePath := filepath.Join(dirPath, "firmware.bin")
// Create the file
file, err := os.Create(filePath)
if err != nil {
return "", 0, fmt.Errorf("failed to create firmware file: %w", err)
}
defer file.Close()
// Copy data to file and calculate size
size, err := io.Copy(file, data)
if err != nil {
return "", 0, fmt.Errorf("failed to write firmware data: %w", err)
}
return filePath, size, nil
}
// GetFirmwareBinaryPath returns the file path for a firmware binary
func (fs *FileStorage) GetFirmwareBinaryPath(name, version string) string {
return filepath.Join(fs.registryPath, name, version, "firmware.bin")
}
// FirmwareExists checks if a firmware binary exists
func (fs *FileStorage) FirmwareExists(name, version string) bool {
filePath := fs.GetFirmwareBinaryPath(name, version)
_, err := os.Stat(filePath)
return !os.IsNotExist(err)
}