// Package database handles SQLite database operations package database import ( "context" "database/sql" "fmt" "os" "path/filepath" "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" ) // DB wraps the database connection with business logic type DB struct { *sql.DB } // Developer represents a developer account type Developer struct { ID string Email string Name string PasswordHash string OAuthProvider string OAuthID string AvatarURL string Verified bool CreatedAt time.Time UpdatedAt time.Time } // NewDB creates a new DB wrapper func NewDB(db *sql.DB) *DB { return &DB{db} } // 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); ` // FindOrCreateDeveloper finds an existing developer by email or creates a new one func (db *DB) FindOrCreateDeveloper(ctx context.Context, dev *Developer) (*Developer, error) { // First try to find by email existing, err := db.GetDeveloperByEmail(ctx, dev.Email) if err == nil { // Update OAuth info if changed if dev.OAuthProvider != "" && (existing.OAuthProvider != dev.OAuthProvider || existing.OAuthID != dev.OAuthID) { _, err := db.ExecContext(ctx, ` UPDATE developers SET oauth_provider = ?, oauth_id = ?, updated_at = datetime('now') WHERE id = ? `, dev.OAuthProvider, dev.OAuthID, existing.ID) if err != nil { return nil, fmt.Errorf("update oauth: %w", err) } existing.OAuthProvider = dev.OAuthProvider existing.OAuthID = dev.OAuthID } return existing, nil } // Create new developer dev.ID = uuid.New().String() _, err = db.ExecContext(ctx, ` INSERT INTO developers (id, email, name, oauth_provider, oauth_id, verified) VALUES (?, ?, ?, ?, ?, 1) `, dev.ID, dev.Email, dev.Name, dev.OAuthProvider, dev.OAuthID) if err != nil { return nil, fmt.Errorf("create developer: %w", err) } dev.Verified = true dev.CreatedAt = time.Now() dev.UpdatedAt = dev.CreatedAt return dev, nil } // GetDeveloper retrieves a developer by ID func (db *DB) GetDeveloper(ctx context.Context, id string) (*Developer, error) { row := db.QueryRowContext(ctx, ` SELECT id, email, name, password_hash, oauth_provider, oauth_id, verified, created_at, updated_at FROM developers WHERE id = ? `, id) return scanDeveloper(row) } // GetDeveloperByEmail retrieves a developer by email func (db *DB) GetDeveloperByEmail(ctx context.Context, email string) (*Developer, error) { row := db.QueryRowContext(ctx, ` SELECT id, email, name, password_hash, oauth_provider, oauth_id, verified, created_at, updated_at FROM developers WHERE email = ? `, email) return scanDeveloper(row) } func scanDeveloper(row *sql.Row) (*Developer, error) { var dev Developer var passwordHash, oauthProvider, oauthID sql.NullString var createdAt, updatedAt string err := row.Scan(&dev.ID, &dev.Email, &dev.Name, &passwordHash, &oauthProvider, &oauthID, &dev.Verified, &createdAt, &updatedAt) if err != nil { return nil, err } dev.PasswordHash = passwordHash.String dev.OAuthProvider = oauthProvider.String dev.OAuthID = oauthID.String dev.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) dev.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) return &dev, nil } // ValidateAPIKey validates an API key and returns the associated developer func (db *DB) ValidateAPIKey(ctx context.Context, key string) (*Developer, error) { // Extract prefix (first 15 chars: mk_live_xxxxxxx) if len(key) < 15 { return nil, fmt.Errorf("invalid key format") } prefix := key[:15] // Find key by prefix row := db.QueryRowContext(ctx, ` SELECT k.key_hash, k.developer_id, k.expires_at FROM api_keys k WHERE k.key_prefix = ? `, prefix) var keyHash, developerID string var expiresAt sql.NullString if err := row.Scan(&keyHash, &developerID, &expiresAt); err != nil { return nil, fmt.Errorf("key not found") } // Check expiration if expiresAt.Valid { expiry, err := time.Parse("2006-01-02 15:04:05", expiresAt.String) if err == nil && time.Now().After(expiry) { return nil, fmt.Errorf("key expired") } } // Verify key hash if err := bcrypt.CompareHashAndPassword([]byte(keyHash), []byte(key)); err != nil { return nil, fmt.Errorf("invalid key") } // Update last used db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = datetime('now') WHERE key_prefix = ?`, prefix) // Get developer return db.GetDeveloper(ctx, developerID) } // LogAudit logs an audit event func (db *DB) LogAudit(ctx context.Context, developerID, action, ipAddress, userAgent string, success bool, failureReason string) { details := "" if !success { details = fmt.Sprintf(`{"success":false,"reason":"%s"}`, failureReason) } else { details = `{"success":true}` } db.ExecContext(ctx, ` INSERT INTO audit_logs (developer_id, action, details, ip_address, user_agent) VALUES (?, ?, ?, ?, ?) `, developerID, action, details, ipAddress, userAgent) }