diff --git a/go.mod b/go.mod index 4264967..e1d2854 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/go-chi/chi/v5 v5.3.0 github.com/go-sql-driver/mysql v1.10.0 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.6 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/ulikunitz/xz v0.5.15 golang.org/x/crypto v0.53.0 golang.org/x/term v0.44.0 @@ -19,10 +19,10 @@ require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect golang.org/x/sys v0.46.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/api/csrf.go b/internal/api/csrf.go deleted file mode 100644 index bf34cf3..0000000 --- a/internal/api/csrf.go +++ /dev/null @@ -1,25 +0,0 @@ -package api - -import ( - "net/http" - "strings" -) - -func CSRFProtection(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" { - // Skip CSRF check if using API Key / Bearer token - if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { - next.ServeHTTP(w, r) - return - } - origin := r.Header.Get("Origin") - referer := r.Header.Get("Referer") - if origin == "" && referer == "" { - http.Error(w, "Missing CSRF headers", http.StatusForbidden) - return - } - } - next.ServeHTTP(w, r) - }) -} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 49f71c0..3f501f4 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -4,10 +4,42 @@ import ( "encoding/json" "log" "net/http" + "strings" "github.com/kernelcode0/aptify/internal/storage" ) +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;") + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + next.ServeHTTP(w, r) + }) +} + +func CSRFProtection(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" { + // Skip CSRF check if using API Key / Bearer token + if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + next.ServeHTTP(w, r) + return + } + origin := r.Header.Get("Origin") + referer := r.Header.Get("Referer") + if origin == "" && referer == "" { + http.Error(w, "Missing CSRF headers", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} + func RequireRole(roles ...string) func(http.Handler) http.Handler { allowed := make(map[string]bool, len(roles)) for _, role := range roles { diff --git a/internal/api/packages.go b/internal/api/packages.go index ae6c95b..2c810bd 100644 --- a/internal/api/packages.go +++ b/internal/api/packages.go @@ -301,10 +301,7 @@ func (h *Handler) deletePackage(w http.ResponseWriter, r *http.Request) { return } - if err := h.regenerateRepoIndex(repo); err != nil { - jsonError(w, "index regenerate error", http.StatusInternalServerError) - return - } + h.enqueueIndex(repo.ID) var deleteErr error if repo.Type == "rpm" { deleteErr = h.fs.DeleteRPMPackageFile(repo.Slug, pkg.Filename) diff --git a/internal/api/repos.go b/internal/api/repos.go index d371ec9..79bd5e5 100644 --- a/internal/api/repos.go +++ b/internal/api/repos.go @@ -166,10 +166,7 @@ func (h *Handler) updateRepo(w http.ResponseWriter, r *http.Request) { if codenameChanged { // Regenerate index in new path - if err := h.regenerateRepoIndex(repo); err != nil { - jsonError(w, "index regenerate error", http.StatusInternalServerError) - return - } + h.enqueueIndex(repo.ID) } jsonOK(w, repo, http.StatusOK) diff --git a/internal/api/router.go b/internal/api/router.go index 77ef951..6818f20 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6,7 +6,6 @@ import ( "net/http" "path/filepath" "strings" - "sync" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -31,7 +30,6 @@ type Handler struct { signer *signing.Signer jwtSecret string version string - indexMu sync.Mutex queue *indexqueue.Queue } @@ -171,15 +169,10 @@ func (h *Handler) authMiddleware(next http.Handler) http.Handler { }) } -func (h *Handler) regenerateRepoIndex(repo *storage.Repo) error { - h.indexMu.Lock() - defer h.indexMu.Unlock() - - packages, err := h.db.ListPackages(repo.ID, 0, 0) - if err != nil { - return err +func (h *Handler) enqueueIndex(repoID string) { + if h.queue != nil { + h.queue.Enqueue(repoID) } - return h.gen.Regenerate(repo, packages) } func (h *Handler) servePublicKey(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/security_headers.go b/internal/api/security_headers.go deleted file mode 100644 index 1a256a1..0000000 --- a/internal/api/security_headers.go +++ /dev/null @@ -1,15 +0,0 @@ -package api - -import "net/http" - -func SecurityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;") - w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") - w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") - next.ServeHTTP(w, r) - }) -} diff --git a/internal/deb/ar.go b/internal/deb/ar.go index 31ed65f..2a7f60f 100644 --- a/internal/deb/ar.go +++ b/internal/deb/ar.go @@ -20,7 +20,7 @@ func newArReader(r io.Reader) *arReader { } type arEntry struct { - r io.Reader + r *io.LimitedReader size int64 } @@ -46,6 +46,13 @@ func (a *arReader) Next() (string, io.ReadCloser, error) { return "", nil, fmt.Errorf("not an ar archive") } a.pos = 8 + } else if a.pos%2 != 0 { + // Entries are 2-byte aligned; discard the odd-byte padding. + var pad [1]byte + if _, err := io.ReadFull(a.r, pad[:]); err != nil { + return "", nil, err + } + a.pos++ } // Each entry header is 60 bytes. @@ -75,33 +82,5 @@ func (a *arReader) Next() (string, io.ReadCloser, error) { lr := &io.LimitedReader{R: a.r, N: size} a.pos += size - // Entries are 2-byte aligned; skip padding byte if needed. - entry := &arEntry{ - r: newPaddedReader(lr, size), - size: size, - } - return name, entry, nil -} - -// paddedReader wraps a LimitedReader and discards a padding byte when the -// underlying size is odd (ar archives pad entries to 2-byte boundaries). -type paddedReader struct { - lr *io.LimitedReader - size int64 - doneN int64 -} - -func newPaddedReader(lr *io.LimitedReader, size int64) io.Reader { - return &paddedReader{lr: lr, size: size} -} - -func (p *paddedReader) Read(buf []byte) (int, error) { - n, err := p.lr.Read(buf) - p.doneN += int64(n) - if err == io.EOF && p.size%2 != 0 { - // Discard the padding byte. - pad := make([]byte, 1) - _, _ = p.lr.R.Read(pad) - } - return n, err + return name, &arEntry{r: lr, size: size}, nil } diff --git a/internal/deb/ar_test.go b/internal/deb/ar_test.go new file mode 100644 index 0000000..18fff6f --- /dev/null +++ b/internal/deb/ar_test.go @@ -0,0 +1,124 @@ +package deb + +import ( + "bytes" + "fmt" + "io" + "testing" +) + +func TestArReader(t *testing.T) { + // Test files with odd and even lengths: + files := []struct { + name string + content string + }{ + {"file1", "hello"}, // 5 bytes (odd) + {"file2", "even"}, // 4 bytes (even) + {"file3", "testing"}, // 7 bytes (odd) + {"file4", "aligned1234"}, // 11 bytes (odd) + } + + var arData bytes.Buffer + arData.WriteString("!\n") + for _, f := range files { + hdr := fmt.Sprintf("%-16s%-12s%-6s%-6s%-8s%-10d`\n", f.name+"/", "0", "0", "0", "100644", len(f.content)) + arData.WriteString(hdr) + arData.WriteString(f.content) + if len(f.content)%2 != 0 { + arData.WriteByte('\n') + } + } + + ar := newArReader(bytes.NewReader(arData.Bytes())) + + for _, expected := range files { + name, rc, err := ar.Next() + if err != nil { + t.Fatalf("expected next file %s, got error: %v", expected.name, err) + } + if name != expected.name { + t.Errorf("expected name %q, got %q", expected.name, name) + } + + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if string(data) != expected.content { + t.Errorf("file %s: expected content %q, got %q", expected.name, expected.content, string(data)) + } + if err := rc.Close(); err != nil { + t.Errorf("close %s: %v", expected.name, err) + } + } + + // Next call should return io.EOF + _, _, err := ar.Next() + if err != io.EOF { + t.Errorf("expected EOF, got %v", err) + } +} + +func TestArReaderCloseDrain(t *testing.T) { + files := []struct { + name string + content string + }{ + {"f1", "oddlengthcontent"}, // 17 bytes (odd) + {"f2", "secondfile"}, // 10 bytes (even) + } + + var arData bytes.Buffer + arData.WriteString("!\n") + for _, f := range files { + hdr := fmt.Sprintf("%-16s%-12s%-6s%-6s%-8s%-10d`\n", f.name+"/", "0", "0", "0", "100644", len(f.content)) + arData.WriteString(hdr) + arData.WriteString(f.content) + if len(f.content)%2 != 0 { + arData.WriteByte('\n') + } + } + + ar := newArReader(bytes.NewReader(arData.Bytes())) + + // Read only 2 bytes from f1, then Close() immediately + name, rc, err := ar.Next() + if err != nil { + t.Fatalf("Next f1: %v", err) + } + if name != "f1" { + t.Fatalf("got name %q, want f1", name) + } + buf := make([]byte, 2) + if _, err := io.ReadFull(rc, buf); err != nil { + t.Fatalf("read 2 bytes: %v", err) + } + if err := rc.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + // Should still cleanly read f2 + name2, rc2, err := ar.Next() + if err != nil { + t.Fatalf("Next f2: %v", err) + } + if name2 != "f2" { + t.Fatalf("got name %q, want f2", name2) + } + all2, err := io.ReadAll(rc2) + if err != nil { + t.Fatalf("read f2: %v", err) + } + if string(all2) != "secondfile" { + t.Errorf("got %q, want secondfile", string(all2)) + } +} + +func TestArReaderInvalidSig(t *testing.T) { + ar := newArReader(bytes.NewReader([]byte("not-ar-file-data"))) + _, _, err := ar.Next() + if err == nil { + t.Error("expected error for invalid archive signature, got nil") + } +} diff --git a/internal/deb/parse.go b/internal/deb/parse.go index 25b26c6..838fde9 100644 --- a/internal/deb/parse.go +++ b/internal/deb/parse.go @@ -76,14 +76,8 @@ func Parse(r io.Reader) (*Info, error) { // extractControl reads the ar archive and returns the raw content of the // control file found inside control.tar.{gz,bz2,xz}. -func extractControl(r io.ReaderAt) (string, error) { - size, err := r.(interface{ Seek(int64, int) (int64, error) }).Seek(0, io.SeekEnd) - if err != nil { - // Fall back: read all into buffer to determine size. - return "", fmt.Errorf("seek: %w", err) - } - - ar := newArReader(io.NewSectionReader(r, 0, size)) +func extractControl(r io.Reader) (string, error) { + ar := newArReader(r) for { name, rc, err := ar.Next() if err == io.EOF { diff --git a/internal/storage/db.go b/internal/storage/db.go index e353522..ba315e7 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -2,12 +2,13 @@ package storage import ( "context" + "crypto/rand" "database/sql" "fmt" + "strings" "time" _ "github.com/go-sql-driver/mysql" - "github.com/google/uuid" _ "modernc.org/sqlite" ) @@ -108,31 +109,35 @@ func Open(dbType, dsn string) (*DB, error) { return d, nil } +func newUUID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 // RFC 4122 version 4 + b[8] = (b[8] & 0x3f) | 0x80 // RFC 4122 variant + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + func (d *DB) migrate() error { - var repoTable, pkgTable, userTable, apiKeyTable, auditLogTable string - if d.dbType == "mysql" { - userTable = ` - CREATE TABLE IF NOT EXISTS users ( + tables := []string{ + `CREATE TABLE IF NOT EXISTS users ( id VARCHAR(36) PRIMARY KEY, username VARCHAR(255) UNIQUE NOT NULL, role VARCHAR(32) NOT NULL DEFAULT 'viewer', password_hash VARCHAR(255) NOT NULL, totp_enabled TINYINT(1) NOT NULL DEFAULT 0, totp_secret VARCHAR(64) NOT NULL DEFAULT '', - totp_recovery_codes TEXT, + totp_recovery_codes TEXT NOT NULL DEFAULT '[]', created_at DATETIME NOT NULL - );` - repoTable = ` - CREATE TABLE IF NOT EXISTS repos ( + );`, + `CREATE TABLE IF NOT EXISTS repos ( id VARCHAR(36) PRIMARY KEY, slug VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, codename VARCHAR(255) NOT NULL DEFAULT 'stable', type VARCHAR(16) NOT NULL DEFAULT 'deb', created_at DATETIME NOT NULL - );` - pkgTable = ` - CREATE TABLE IF NOT EXISTS packages ( + );`, + `CREATE TABLE IF NOT EXISTS packages ( id VARCHAR(36) PRIMARY KEY, repo_id VARCHAR(36) NOT NULL, filename VARCHAR(255) NOT NULL, @@ -144,14 +149,12 @@ func (d *DB) migrate() error { sha256 VARCHAR(64) NOT NULL, sha1 VARCHAR(40) NOT NULL, md5 VARCHAR(32) NOT NULL, - control_json TEXT NOT NULL, + control_json TEXT NOT NULL DEFAULT '{}', uploaded_at DATETIME NOT NULL, - FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE, - INDEX idx_packages_repo (repo_id) - );` + FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE + );`, /* #nosec G101 */ - apiKeyTable = ` - CREATE TABLE IF NOT EXISTS api_keys ( + `CREATE TABLE IF NOT EXISTS api_keys ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, @@ -160,232 +163,81 @@ func (d *DB) migrate() error { created_at DATETIME NOT NULL, last_used DATETIME, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - );` - auditLogTable = ` - CREATE TABLE IF NOT EXISTS audit_log ( + );`, + `CREATE TABLE IF NOT EXISTS audit_log ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, username VARCHAR(255) NOT NULL, action VARCHAR(64) NOT NULL, resource VARCHAR(255) NOT NULL, detail TEXT, - created_at DATETIME NOT NULL, - INDEX idx_audit_created (created_at), - INDEX idx_audit_user (user_id) - );` - } else { - userTable = ` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - role TEXT NOT NULL DEFAULT 'viewer', - password_hash TEXT NOT NULL, - totp_enabled INTEGER NOT NULL DEFAULT 0, - totp_secret TEXT NOT NULL DEFAULT '', - totp_recovery_codes TEXT NOT NULL DEFAULT '[]', - created_at DATETIME NOT NULL - );` - repoTable = ` - CREATE TABLE IF NOT EXISTS repos ( - id TEXT PRIMARY KEY, - slug TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - codename TEXT NOT NULL DEFAULT 'stable', - type TEXT NOT NULL DEFAULT 'deb', - created_at DATETIME NOT NULL - );` - pkgTable = ` - CREATE TABLE IF NOT EXISTS packages ( - id TEXT PRIMARY KEY, - repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, - filename TEXT NOT NULL, - package TEXT NOT NULL, - version TEXT NOT NULL, - release TEXT NOT NULL DEFAULT '', - arch TEXT NOT NULL, - size INTEGER NOT NULL, - sha256 TEXT NOT NULL, - sha1 TEXT NOT NULL, - md5 TEXT NOT NULL, - control_json TEXT NOT NULL DEFAULT '{}', - uploaded_at DATETIME NOT NULL - );` - /* #nosec G101 */ - apiKeyTable = ` - CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - prefix TEXT NOT NULL, - created_at DATETIME NOT NULL, - last_used DATETIME - );` - auditLogTable = ` - CREATE TABLE IF NOT EXISTS audit_log ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - resource TEXT NOT NULL, - detail TEXT, created_at DATETIME NOT NULL - );` + );`, } - if _, err := d.db.Exec(userTable); err != nil { - return err - } - if _, err := d.db.Exec(repoTable); err != nil { - return err - } - if _, err := d.db.Exec(pkgTable); err != nil { - return err - } - if _, err := d.db.Exec(apiKeyTable); err != nil { - return err - } - if _, err := d.db.Exec(auditLogTable); err != nil { - return err - } - if err := d.ensureUserRoleColumn(); err != nil { - return err - } - if err := d.ensureUser2FAColumns(); err != nil { - return err - } - if err := d.ensureRepoTypeColumn(); err != nil { - return err - } - if err := d.ensurePackageReleaseColumn(); err != nil { - return err - } - if d.dbType == "sqlite" { - if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_packages_repo ON packages(repo_id);`); err != nil { - return err - } - if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);`); err != nil { - return err - } - if _, err := d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);`); err != nil { + for _, tbl := range tables { + if _, err := d.db.Exec(tbl); err != nil { return err } } - return nil -} -func (d *DB) ensureRepoTypeColumn() error { - exists, err := d.columnExists("repos", "type") - if err != nil { + // Ensure legacy columns exist for existing databases + if added, err := d.ensureColumn("users", "role", "VARCHAR(32) NOT NULL DEFAULT 'viewer'"); err != nil { return err + } else if added { + _, _ = d.db.Exec(`UPDATE users SET role='admin'`) } - if exists { - return nil - } - colType := "TEXT" - if d.dbType == "mysql" { - colType = "VARCHAR(16)" - } - _, err = d.db.Exec(`ALTER TABLE repos ADD COLUMN type ` + colType + ` NOT NULL DEFAULT 'deb'`) - return err -} - -func (d *DB) ensurePackageReleaseColumn() error { - exists, err := d.columnExists("packages", "release") - if err != nil { + if _, err := d.ensureColumn("users", "totp_enabled", "TINYINT(1) NOT NULL DEFAULT 0"); err != nil { return err } - if exists { - return nil - } - if d.dbType == "mysql" { - _, err = d.db.Exec("ALTER TABLE packages ADD COLUMN `release` VARCHAR(255) NOT NULL DEFAULT ''") - } else { - _, err = d.db.Exec("ALTER TABLE packages ADD COLUMN release TEXT NOT NULL DEFAULT ''") - } - return err -} - -func (d *DB) ensureUserRoleColumn() error { - exists, err := d.columnExists("users", "role") - if err != nil { + if _, err := d.ensureColumn("users", "totp_secret", "VARCHAR(64) NOT NULL DEFAULT ''"); err != nil { return err } - if exists { - return nil - } - roleType := "TEXT" - if d.dbType == "mysql" { - roleType = "VARCHAR(32)" - } - if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN role ` + roleType + ` NOT NULL DEFAULT 'viewer'`); err != nil { + if _, err := d.ensureColumn("users", "totp_recovery_codes", "TEXT NOT NULL DEFAULT '[]'"); err != nil { return err } - _, err = d.db.Exec(`UPDATE users SET role=?`, "admin") - return err -} - -func (d *DB) ensureUser2FAColumns() error { - hasEnabled, err := d.columnExists("users", "totp_enabled") - if err != nil { + if _, err := d.ensureColumn("repos", "type", "VARCHAR(16) NOT NULL DEFAULT 'deb'"); err != nil { return err } - if !hasEnabled { - colType := "INTEGER NOT NULL DEFAULT 0" - if d.dbType == "mysql" { - colType = "TINYINT(1) NOT NULL DEFAULT 0" - } - if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_enabled ` + colType); err != nil { - return err - } - } - - hasSecret, err := d.columnExists("users", "totp_secret") - if err != nil { + if _, err := d.ensureColumn("packages", "`release`", "VARCHAR(255) NOT NULL DEFAULT ''"); err != nil { return err } - if !hasSecret { - colType := "TEXT NOT NULL DEFAULT ''" - if d.dbType == "mysql" { - colType = "VARCHAR(64) NOT NULL DEFAULT ''" - } - if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_secret ` + colType); err != nil { - return err - } - } - hasCodes, err := d.columnExists("users", "totp_recovery_codes") - if err != nil { - return err - } - if !hasCodes { - if d.dbType == "mysql" { - if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT`); err != nil { - return err - } - _, _ = d.db.Exec(`UPDATE users SET totp_recovery_codes='[]' WHERE totp_recovery_codes IS NULL OR totp_recovery_codes=''`) - } else { - if _, err := d.db.Exec(`ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]'`); err != nil { + if d.dbType == "sqlite" { + for _, idx := range []string{ + `CREATE INDEX IF NOT EXISTS idx_packages_repo ON packages(repo_id);`, + `CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);`, + `CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);`, + } { + if _, err := d.db.Exec(idx); err != nil { return err } } + } else if d.dbType == "mysql" { + for _, idx := range []string{ + `CREATE INDEX idx_packages_repo ON packages(repo_id)`, + `CREATE INDEX idx_audit_created ON audit_log(created_at)`, + `CREATE INDEX idx_audit_user ON audit_log(user_id)`, + } { + _, _ = d.db.Exec(idx) + } } return nil } -func (d *DB) columnExists(table, column string) (bool, error) { - // Whitelist table names to prevent SQL injection via string concatenation +func (d *DB) ensureColumn(table, column, colDef string) (bool, error) { validTables := map[string]bool{"users": true, "repos": true, "packages": true, "api_keys": true, "audit_log": true} if !validTables[table] { return false, fmt.Errorf("invalid table name") } + rawCol := strings.Trim(column, "`") var rows *sql.Rows var err error if d.dbType == "mysql" { - rows, err = d.db.Query("SHOW COLUMNS FROM "+table+" WHERE Field = ?", column) // #nosec G202 + rows, err = d.db.Query("SHOW COLUMNS FROM "+table+" WHERE Field = ?", rawCol) // #nosec G202 } else { - rows, err = d.db.Query(`PRAGMA table_info(` + table + `)`) // #nosec G202 + rows, err = d.db.Query("PRAGMA table_info(" + table + ")") // #nosec G202 } if err != nil { return false, err @@ -393,22 +245,28 @@ func (d *DB) columnExists(table, column string) (bool, error) { defer rows.Close() if d.dbType == "mysql" { - return rows.Next(), rows.Err() - } - for rows.Next() { - var cid int - var name, colType string - var notNull int - var defaultValue any - var pk int - if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil { - return false, err + if rows.Next() { + return false, rows.Err() } - if name == column { - return true, nil + } else { + for rows.Next() { + var cid, notNull, pk int + var name, colType string + var defaultValue any + if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil { + return false, err + } + if name == rawCol { + return false, nil + } } } - return false, rows.Err() + if err := rows.Err(); err != nil { + return false, err + } + + _, err = d.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, colDef)) + return err == nil, err } // GetUserByUsername retrieves a user by their username. @@ -426,7 +284,7 @@ func (d *DB) GetUserByUsername(username string) (*User, error) { // CreateUser inserts a new user. func (d *DB) CreateUser(username, passwordHash, role string) (*User, error) { u := &User{ - ID: uuid.NewString(), + ID: newUUID(), Username: username, Role: role, PasswordHash: passwordHash, @@ -517,7 +375,7 @@ func (d *DB) DeleteUser(id string) error { // CreateRepo inserts a new repo and returns it. func (d *DB) CreateRepo(slug, name, codename, repoType string) (*Repo, error) { r := &Repo{ - ID: uuid.NewString(), + ID: newUUID(), Slug: slug, Name: name, Codename: codename, @@ -608,7 +466,7 @@ func (d *DB) GetRepoArchitectures(repoID string) ([]string, error) { // AddPackage inserts a package record. func (d *DB) AddPackage(p *Package) error { - p.ID = uuid.NewString() + p.ID = newUUID() p.UploadedAt = time.Now().UTC() _, err := d.db.Exec( `INSERT INTO packages (id, repo_id, filename, package, version, `+"`release`"+`, arch, size, sha256, sha1, md5, control_json, uploaded_at) @@ -708,7 +566,7 @@ func (d *DB) ClearAuditLogExcept(ctx context.Context, keepEventID string) error } func (d *DB) LogAuditWithID(userID, username, action, resource, detail string) (string, error) { - id := uuid.NewString() + id := newUUID() _, err := d.db.Exec(` INSERT INTO audit_log (id, user_id, username, action, resource, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -719,7 +577,7 @@ func (d *DB) LogAuditWithID(userID, username, action, resource, detail string) ( // CreateAPIKey inserts a new API key record. func (d *DB) CreateAPIKey(userID, name, keyHash, prefix string) (*APIKey, error) { k := &APIKey{ - ID: uuid.NewString(), + ID: newUUID(), UserID: userID, Name: name, Prefix: prefix, @@ -793,7 +651,7 @@ func (d *DB) UpdateAPIKeyLastUsed(id string) error { func (d *DB) AddAuditEntry(userID, username, action, resource, detail string) error { _, err := d.db.Exec( `INSERT INTO audit_log (id, user_id, username, action, resource, detail, created_at) VALUES (?,?,?,?,?,?,?)`, - uuid.NewString(), userID, username, action, resource, detail, time.Now().UTC(), + newUUID(), userID, username, action, resource, detail, time.Now().UTC(), ) return err } diff --git a/internal/storage/files.go b/internal/storage/files.go index 106f291..244ccd8 100644 --- a/internal/storage/files.go +++ b/internal/storage/files.go @@ -57,7 +57,6 @@ func (fs *FileStore) IndexDir(slug, codename string) string { // InitRepo creates the required directory tree for a new repo. func (fs *FileStore) InitRepo(slug, codename string) error { dirs := []string{ - fs.PoolDir(slug, "placeholder"), filepath.Join(fs.DistsDir(slug, codename), "main", "binary-amd64"), filepath.Join(fs.DistsDir(slug, codename), "main", "binary-arm64"), filepath.Join(fs.DistsDir(slug, codename), "main", "binary-all"), diff --git a/web/src/api.ts b/web/src/api.ts index de4152f..45c4717 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,17 +1,5 @@ -// getToken/setToken are preserved as no-ops for API key upload path compatibility. -// No token is stored in JavaScript — localStorage was removed to prevent XSS theft. -export function getToken(): string { - return '' -} - -export function setToken(_t: string) { - // no-op -} - async function req(method: string, path: string, body?: unknown): Promise { - const token = getToken() const headers: Record = {} - if (token) headers['Authorization'] = `Bearer ${token}` if (body) headers['Content-Type'] = 'application/json' const res = await fetch(path, { @@ -167,12 +155,10 @@ export const api = { getSetup: (repoId: string) => req('GET', `/api/repos/${repoId}/setup`), getRepoStatus: (repoId: string) => req('GET', `/api/repos/${repoId}/status`), uploadPackage: async (repoId: string, file: File) => { - const token = getToken() const form = new FormData() form.append('file', file) const res = await fetch(`/api/repos/${repoId}/packages`, { method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, credentials: 'same-origin', // Send HttpOnly session cookie body: form, }) diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index cf1860e..3ff3294 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom' import { useEffect, useState } from 'react' -import { api, setToken, type CurrentUser } from '../api' +import { api, type CurrentUser } from '../api' import './Layout.css' function AptifyMark() { @@ -65,7 +65,6 @@ export default function Layout() { } catch { // ignore } - setToken('') navigate('/login') } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index cc0956f..95ad889 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useOutletContext } from 'react-router-dom' -import { api, getToken, type CurrentUser, type Repo } from '../api' +import { api, type CurrentUser, type Repo } from '../api' import './Dashboard.css' type RepoFilter = 'all' | Repo['type'] @@ -73,7 +73,7 @@ export default function Dashboard() { const handleExportKey = async () => { try { - const res = await fetch('/api/system/gpg-key', { headers: { Authorization: `Bearer ${getToken() || ''}` } }) + const res = await fetch('/api/system/gpg-key', { credentials: 'same-origin' }) if (!res.ok) throw new Error('Failed to export GPG key') const url = window.URL.createObjectURL(await res.blob()) const a = document.createElement('a')