mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
309 lines
9.2 KiB
Go
309 lines
9.2 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
//go:embed migrations/*.yaml
|
|
var migrationsFS embed.FS
|
|
|
|
const schema = `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
did TEXT PRIMARY KEY,
|
|
handle TEXT NOT NULL,
|
|
pds_endpoint TEXT NOT NULL,
|
|
avatar TEXT,
|
|
last_seen TIMESTAMP NOT NULL,
|
|
UNIQUE(handle)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle);
|
|
|
|
CREATE TABLE IF NOT EXISTS manifests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
hold_endpoint TEXT NOT NULL,
|
|
schema_version INTEGER NOT NULL,
|
|
media_type TEXT NOT NULL,
|
|
config_digest TEXT,
|
|
config_size INTEGER,
|
|
created_at TIMESTAMP NOT NULL,
|
|
title TEXT,
|
|
description TEXT,
|
|
source_url TEXT,
|
|
documentation_url TEXT,
|
|
licenses TEXT,
|
|
icon_url TEXT,
|
|
UNIQUE(did, repository, digest),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
|
|
|
|
CREATE TABLE IF NOT EXISTS layers (
|
|
manifest_id INTEGER NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
media_type TEXT NOT NULL,
|
|
layer_index INTEGER NOT NULL,
|
|
PRIMARY KEY(manifest_id, layer_index),
|
|
FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest);
|
|
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
tag TEXT NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
UNIQUE(did, repository, tag),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository);
|
|
|
|
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
|
session_key TEXT PRIMARY KEY,
|
|
account_did TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
session_data TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(account_did, session_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS oauth_auth_requests (
|
|
state TEXT PRIMARY KEY,
|
|
request_data TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS ui_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
did TEXT NOT NULL,
|
|
handle TEXT NOT NULL,
|
|
pds_endpoint TEXT NOT NULL,
|
|
oauth_session_id TEXT,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did);
|
|
CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id TEXT PRIMARY KEY,
|
|
did TEXT NOT NULL,
|
|
handle TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
secret_hash TEXT NOT NULL UNIQUE,
|
|
ip_address TEXT,
|
|
location TEXT,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMP NOT NULL,
|
|
last_used TIMESTAMP,
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did);
|
|
CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash);
|
|
|
|
CREATE TABLE IF NOT EXISTS pending_device_auth (
|
|
device_code TEXT PRIMARY KEY,
|
|
user_code TEXT NOT NULL UNIQUE,
|
|
device_name TEXT NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
approved_did TEXT,
|
|
approved_at TIMESTAMP,
|
|
device_secret TEXT,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code);
|
|
CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS repository_stats (
|
|
did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
pull_count INTEGER NOT NULL DEFAULT 0,
|
|
last_pull TIMESTAMP,
|
|
push_count INTEGER NOT NULL DEFAULT 0,
|
|
last_push TIMESTAMP,
|
|
PRIMARY KEY(did, repository),
|
|
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did);
|
|
CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS stars (
|
|
starrer_did TEXT NOT NULL,
|
|
owner_did TEXT NOT NULL,
|
|
repository TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
PRIMARY KEY(starrer_did, owner_did, repository),
|
|
FOREIGN KEY(starrer_did) REFERENCES users(did) ON DELETE CASCADE,
|
|
FOREIGN KEY(owner_did) REFERENCES users(did) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository);
|
|
CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did);
|
|
`
|
|
|
|
// InitDB initializes the SQLite database with the schema
|
|
func InitDB(path string) (*sql.DB, error) {
|
|
db, err := sql.Open("sqlite3", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Enable foreign keys
|
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create schema
|
|
if _, err := db.Exec(schema); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Run migrations
|
|
if err := runMigrations(db); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// Migration represents a database migration
|
|
type Migration struct {
|
|
Version int
|
|
Name string
|
|
Description string `yaml:"description"`
|
|
Query string `yaml:"query"`
|
|
}
|
|
|
|
// runMigrations applies any pending database migrations
|
|
func runMigrations(db *sql.DB) error {
|
|
// Load migrations from files
|
|
migrations, err := loadMigrations()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load migrations: %w", err)
|
|
}
|
|
|
|
// Sort migrations by version
|
|
sort.Slice(migrations, func(i, j int) bool {
|
|
return migrations[i].Version < migrations[j].Version
|
|
})
|
|
|
|
for _, m := range migrations {
|
|
// Check if migration already applied
|
|
var count int
|
|
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.Version).Scan(&count)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check migration status: %w", err)
|
|
}
|
|
|
|
if count > 0 {
|
|
// Migration already applied
|
|
continue
|
|
}
|
|
|
|
// Apply migration
|
|
fmt.Printf("Applying migration %d: %s\n%s\n", m.Version, m.Name, m.Description)
|
|
if _, err := db.Exec(m.Query); err != nil {
|
|
return fmt.Errorf("failed to apply migration %d (%s): %w", m.Version, m.Name, err)
|
|
}
|
|
|
|
// Record migration
|
|
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
|
|
return fmt.Errorf("failed to record migration %d: %w", m.Version, err)
|
|
}
|
|
|
|
fmt.Printf("Migration %d applied successfully\n", m.Version)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadMigrations loads all migration files from embedded filesystem
|
|
func loadMigrations() ([]Migration, error) {
|
|
// Read all migration files from embedded FS
|
|
entries, err := fs.Glob(migrationsFS, "migrations/[0-9][0-9][0-9][0-9]_*.yaml")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list migration files: %w", err)
|
|
}
|
|
|
|
var migrations []Migration
|
|
for _, file := range entries {
|
|
// Parse version and name from filename
|
|
basename := filepath.Base(file)
|
|
version, name, err := parseMigrationFilename(basename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid migration filename %s: %w", basename, err)
|
|
}
|
|
|
|
// Read YAML content from embedded FS
|
|
data, err := migrationsFS.ReadFile(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read migration file %s: %w", file, err)
|
|
}
|
|
|
|
var m Migration
|
|
if err := yaml.Unmarshal(data, &m); err != nil {
|
|
return nil, fmt.Errorf("failed to parse migration file %s: %w", file, err)
|
|
}
|
|
|
|
// Set version and name from filename
|
|
m.Version = version
|
|
m.Name = name
|
|
|
|
// Validate migration
|
|
if m.Query == "" {
|
|
return nil, fmt.Errorf("missing migration 'query' in %s", file)
|
|
}
|
|
|
|
migrations = append(migrations, m)
|
|
}
|
|
|
|
return migrations, nil
|
|
}
|
|
|
|
// parseMigrationFilename extracts version and name from migration filename
|
|
// Expected format: 0001_migration_name.yaml
|
|
// Returns: version (int), name (string), error
|
|
// Note: Glob pattern ensures format is valid, so minimal validation needed
|
|
func parseMigrationFilename(filename string) (int, string, error) {
|
|
// Remove extension (.yaml or .yml)
|
|
ext := filepath.Ext(filename)
|
|
fileNameWithoutExt := filename[:len(filename)-len(ext)]
|
|
|
|
// First 4 characters are the version (glob guarantees they're digits)
|
|
version, _ := strconv.Atoi(fileNameWithoutExt[:4])
|
|
|
|
// Remainder after position 5 is the name (glob guarantees it exists)
|
|
name := strings.ReplaceAll(fileNameWithoutExt[5:], "_", " ")
|
|
name = strings.TrimSpace(name)
|
|
|
|
return version, name, nil
|
|
}
|