e30c3b632a
Forges (multi-host) + tokens, project directories, and git identity now live in a private SQLite config store (internal/store, modernc.org/sqlite) on a /data named volume that is not bind-mounted or exposed, so credentials aren't reachable outside the container. New Settings page (/settings) + <settings-panel> with /api/config CRUD. Scanner reads roots fresh from the store each cycle; service resolves forges per-repo from the store and reapplies per-forge git auth on change. First run seeds the store from .env. Overturns the old no-datastore/.env-config laws (AGENT.md updated). Verified live end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
7.2 KiB
Go
243 lines
7.2 KiB
Go
// Package store is GitManager's small SQLite-backed configuration store: forge
|
|
// hosts + credentials, the project directories to scan, and a few settings
|
|
// (git identity). It replaces the domain config that used to live in .env.
|
|
//
|
|
// The DB file lives on a PRIVATE named Docker volume (not bind-mounted into the
|
|
// project, no network port), so it is not accessible outside the container
|
|
// (AGENT.md §0/§1.3). Tokens are stored as-is, relying on that isolation. Pure-Go
|
|
// driver (modernc.org/sqlite) so the static CGO_ENABLED=0 build keeps working.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Forge is a configured hosting provider (Gitea/Forgejo, later GitHub/GitLab).
|
|
type Forge struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"` // "gitea" (only kind for now)
|
|
BaseURL string `json:"baseUrl"` // e.g. https://git.nilles.net
|
|
Token string `json:"-"` // never serialized to the client
|
|
HasToken bool `json:"hasToken"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// ProjectDir is a directory (inside the container) to scan for repositories.
|
|
type ProjectDir struct {
|
|
ID int64 `json:"id"`
|
|
Path string `json:"path"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// Store wraps the SQLite connection.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// Open opens (creating if needed) the SQLite DB at path and runs migrations.
|
|
func Open(path string) (*Store, error) {
|
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1) // SQLite: serialize writers to avoid "database is locked"
|
|
s := &Store{db: db}
|
|
if err := s.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
func (s *Store) migrate() error {
|
|
_, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS forges (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
kind TEXT NOT NULL DEFAULT 'gitea',
|
|
base_url TEXT NOT NULL UNIQUE,
|
|
token TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS project_dirs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
path TEXT NOT NULL UNIQUE,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);`)
|
|
return err
|
|
}
|
|
|
|
// --- forges ----------------------------------------------------------------
|
|
|
|
func (s *Store) ListForges(ctx context.Context) ([]Forge, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, kind, base_url, token, created_at FROM forges ORDER BY base_url`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Forge
|
|
for rows.Next() {
|
|
var f Forge
|
|
var created string
|
|
if err := rows.Scan(&f.ID, &f.Name, &f.Kind, &f.BaseURL, &f.Token, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
f.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
|
f.HasToken = f.Token != ""
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) AddForge(ctx context.Context, f Forge) (Forge, error) {
|
|
if f.Kind == "" {
|
|
f.Kind = "gitea"
|
|
}
|
|
f.CreatedAt = time.Now()
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO forges (name, kind, base_url, token, created_at) VALUES (?,?,?,?,?)`,
|
|
f.Name, f.Kind, f.BaseURL, f.Token, f.CreatedAt.Format(time.RFC3339))
|
|
if err != nil {
|
|
return Forge{}, err
|
|
}
|
|
f.ID, _ = res.LastInsertId()
|
|
f.HasToken = f.Token != ""
|
|
return f, nil
|
|
}
|
|
|
|
// UpdateForge updates name/base_url/kind, and the token only when newToken != ""
|
|
// (empty means "keep the existing token").
|
|
func (s *Store) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, newToken string) error {
|
|
if kind == "" {
|
|
kind = "gitea"
|
|
}
|
|
if newToken != "" {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE forges SET name=?, kind=?, base_url=?, token=? WHERE id=?`,
|
|
name, kind, baseURL, newToken, id)
|
|
return err
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE forges SET name=?, kind=?, base_url=? WHERE id=?`, name, kind, baseURL, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteForge(ctx context.Context, id int64) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM forges WHERE id=?`, id)
|
|
return err
|
|
}
|
|
|
|
// --- project dirs ----------------------------------------------------------
|
|
|
|
func (s *Store) ListProjectDirs(ctx context.Context) ([]ProjectDir, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, path, enabled, created_at FROM project_dirs ORDER BY path`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []ProjectDir
|
|
for rows.Next() {
|
|
var d ProjectDir
|
|
var created string
|
|
var enabled int
|
|
if err := rows.Scan(&d.ID, &d.Path, &enabled, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
d.Enabled = enabled != 0
|
|
d.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// EnabledRoots returns the paths of enabled project directories.
|
|
func (s *Store) EnabledRoots(ctx context.Context) ([]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT path FROM project_dirs WHERE enabled=1 ORDER BY path`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var p string
|
|
if err := rows.Scan(&p); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) AddProjectDir(ctx context.Context, path string) (ProjectDir, error) {
|
|
d := ProjectDir{Path: path, Enabled: true, CreatedAt: time.Now()}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO project_dirs (path, enabled, created_at) VALUES (?,1,?)`,
|
|
path, d.CreatedAt.Format(time.RFC3339))
|
|
if err != nil {
|
|
return ProjectDir{}, err
|
|
}
|
|
d.ID, _ = res.LastInsertId()
|
|
return d, nil
|
|
}
|
|
|
|
func (s *Store) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error {
|
|
v := 0
|
|
if enabled {
|
|
v = 1
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `UPDATE project_dirs SET enabled=? WHERE id=?`, v, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteProjectDir(ctx context.Context, id int64) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM project_dirs WHERE id=?`, id)
|
|
return err
|
|
}
|
|
|
|
// --- settings (kv) ---------------------------------------------------------
|
|
|
|
func (s *Store) GetSetting(ctx context.Context, key, def string) (string, error) {
|
|
var v string
|
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=?`, key).Scan(&v)
|
|
if err == sql.ErrNoRows {
|
|
return def, nil
|
|
}
|
|
if err != nil {
|
|
return def, err
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO settings (key, value) VALUES (?,?)
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
|
|
return err
|
|
}
|
|
|
|
// IsEmpty reports whether the store has no forges and no project dirs (used to
|
|
// decide whether to seed from .env on first run).
|
|
func (s *Store) IsEmpty(ctx context.Context) (bool, error) {
|
|
var n int
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT (SELECT COUNT(*) FROM forges) + (SELECT COUNT(*) FROM project_dirs)`).Scan(&n); err != nil {
|
|
return false, err
|
|
}
|
|
return n == 0, nil
|
|
}
|