Files
beardrive/internal/webapp/db_sql.go
T
Snow W. Lee (Sungwon)andGitHub 65491f9576 feat(hub): rename, describe and pick an icon for a project, from its own Settings page (#51)
Project gains two optional fields — Description (<=280 chars) and Icon (a
lucide icon name) — and PATCH /api/projects/{id} becomes a real partial
update: every field is a *string, so only the keys present in the body
change, and {"description":""} clears where an omitted key leaves alone.
Validation returns 400 for an empty or >120-char name, a sibling-name
collision, a >280-char description, and an icon failing ^[a-z0-9-]{1,32}$.
The permission gate is deliberately untouched.

Storage: the file backend marshals Project whole, so it rides along; the SQL
backend needs the two columns added to an already-created table, which
CREATE TABLE IF NOT EXISTS can't do — hence addColumns(), an idempotent
ALTER helper (same shape BEA-2 introduces for creator/default_level, so the
two merge into one map).

Frontend: Settings is now shadcn sectioned cards (General / About / Danger
zone — adds card, separator, textarea to components/ui), with an RHF+zod
form that PATCHes only its dirty keys and refreshes the hub queries, so the
nav mark and dashboard header update without a reload. Icons come from a
curated ~30-icon lucide shortlist (named imports, so Vite still tree-shakes
the rest); an unknown or empty name renders the folder placeholder. The
glyph shows in the project mark on the switcher trigger and every menu row,
and beside the name on the dashboard header with the description under it.
The org admin panel loses its per-project Rename button, which collapses its
two project lists into one read-only list for everybody.

One fix found while driving the real UI: Tailwind preflight is off in this
app, so copied shadcn form controls rendered monospace/black and cards drew
a near-white hairline. Both are now supplied by slot in style.css.
2026-07-27 09:56:59 +09:00

564 lines
17 KiB
Go

package webapp
import (
"database/sql"
"fmt"
"strings"
"time"
// Pure-Go drivers only, so static (CGO-free) builds keep working.
_ "github.com/jackc/pgx/v5/stdlib" // "pgx" — Postgres / Supabase
_ "modernc.org/sqlite" // "sqlite" — embedded local DB
)
// The SQL backend: one *sql.DB shared by the repos, targeting SQLite (local)
// or Postgres/Supabase (production) through the same portable schema. Each
// record is a real row; multi-step writes (an org and its members) run in a
// transaction. Timestamps are stored as RFC3339 text and booleans as 0/1 so
// the same statements work on both engines.
type dialect int
const (
dialectSQLite dialect = iota
dialectPostgres
)
type sqlMetaStore struct {
db *sql.DB
d dialect
accounts *sqlAccountRepo
projects *sqlProjectRepo
orgs *sqlOrgRepo
shares *sqlShareRepo
devices *sqlDeviceRepo
reads *sqlReadRepo
}
// OpenSQLStore opens (and migrates) a SQL metadata store. driver is "sqlite"
// or "pgx" (Postgres/Supabase); dsn is the connection string / file path.
func OpenSQLStore(driver, dsn string) (MetaStore, error) {
db, err := sql.Open(driver, dsn)
if err != nil {
return nil, fmt.Errorf("open %s: %w", driver, err)
}
d := dialectSQLite
switch driver {
case "pgx", "postgres", "pgx/v5":
d = dialectPostgres
case "sqlite", "sqlite3":
d = dialectSQLite
default:
db.Close()
return nil, fmt.Errorf("unsupported database driver %q (use sqlite or pgx)", driver)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("connect %s: %w", driver, err)
}
s := &sqlMetaStore{db: db, d: d}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
s.accounts = &sqlAccountRepo{s}
s.projects = &sqlProjectRepo{s}
s.orgs = &sqlOrgRepo{s}
s.shares = &sqlShareRepo{s}
s.devices = &sqlDeviceRepo{s}
s.reads = &sqlReadRepo{s}
return s, nil
}
func (s *sqlMetaStore) Accounts() AccountRepo { return s.accounts }
func (s *sqlMetaStore) Projects() ProjectRepo { return s.projects }
func (s *sqlMetaStore) Orgs() OrgRepo { return s.orgs }
func (s *sqlMetaStore) Shares() ShareRepo { return s.shares }
func (s *sqlMetaStore) Devices() DeviceRepo { return s.devices }
func (s *sqlMetaStore) Reads() ReadRepo { return s.reads }
func (s *sqlMetaStore) Close() error { return s.db.Close() }
// q rebinds ?-placeholders to $1,$2,… for Postgres; SQLite keeps ?.
func (s *sqlMetaStore) q(query string) string {
if s.d != dialectPostgres {
return query
}
var b strings.Builder
n := 0
for _, r := range query {
if r == '?' {
n++
fmt.Fprintf(&b, "$%d", n)
} else {
b.WriteRune(r)
}
}
return b.String()
}
func (s *sqlMetaStore) exec(query string, args ...any) error {
_, err := s.db.Exec(s.q(query), args...)
return err
}
// tenc / tdec store times as RFC3339 text (empty string for the zero time),
// avoiding per-driver timestamp scanning differences.
func tenc(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
func tdec(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
func (s *sqlMetaStore) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY, email TEXT NOT NULL, name TEXT NOT NULL,
pass TEXT NOT NULL, status TEXT NOT NULL DEFAULT '', created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS tokens (
hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, device TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS auth_policy (
id INTEGER PRIMARY KEY, require_verification INTEGER NOT NULL DEFAULT 0,
require_approval INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY, name TEXT NOT NULL, org TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS orgs (
id TEXT PRIMARY KEY, name TEXT NOT NULL, created TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS org_members (
org TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, PRIMARY KEY (org, email))`,
`CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY, org TEXT NOT NULL, creator TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '', expires TEXT NOT NULL DEFAULT '', uses INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY, project TEXT NOT NULL, path TEXT NOT NULL,
creator TEXT NOT NULL DEFAULT '', created TEXT NOT NULL DEFAULT '', expires TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', os TEXT NOT NULL DEFAULT '',
user_email TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', last_seen TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS read_stats (
project TEXT NOT NULL, path TEXT NOT NULL, day TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL, actor TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0, last TEXT NOT NULL DEFAULT '',
PRIMARY KEY (project, path, day, kind, actor))`,
}
for _, st := range stmts {
if _, err := s.db.Exec(st); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
// Columns added after the tables shipped. CREATE TABLE IF NOT EXISTS does
// nothing for an existing table, so these need a real (idempotent) ALTER.
return s.addColumns("projects", map[string]string{
"description": `TEXT NOT NULL DEFAULT ''`,
"icon": `TEXT NOT NULL DEFAULT ''`,
})
}
// addColumns adds any of cols that the table doesn't already have. The live
// column set comes from an empty result set's metadata, which both drivers
// (modernc/sqlite and pgx) report the same way — no engine-specific catalog
// query, and safe to run on every start.
func (s *sqlMetaStore) addColumns(table string, cols map[string]string) error {
rows, err := s.db.Query(`SELECT * FROM ` + table + ` LIMIT 0`)
if err != nil {
return fmt.Errorf("migrate %s: %w", table, err)
}
names, err := rows.Columns()
rows.Close()
if err != nil {
return fmt.Errorf("migrate %s: %w", table, err)
}
have := make(map[string]bool, len(names))
for _, n := range names {
have[strings.ToLower(n)] = true
}
for col, spec := range cols {
if have[col] {
continue
}
if _, err := s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + col + ` ` + spec); err != nil {
return fmt.Errorf("migrate %s.%s: %w", table, col, err)
}
}
return nil
}
// ---- accounts ----
type sqlAccountRepo struct{ s *sqlMetaStore }
func (r *sqlAccountRepo) Load() ([]*authUser, []authToken, *authPolicy, error) {
var users []*authUser
rows, err := r.s.db.Query(`SELECT id, email, name, pass, status, created FROM accounts`)
if err != nil {
return nil, nil, nil, err
}
for rows.Next() {
var u authUser
var created string
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Pass, &u.Status, &created); err != nil {
rows.Close()
return nil, nil, nil, err
}
u.Created = tdec(created)
users = append(users, &u)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, nil, err
}
var tokens []authToken
rows, err = r.s.db.Query(`SELECT hash, user_id, device, created FROM tokens`)
if err != nil {
return nil, nil, nil, err
}
for rows.Next() {
var t authToken
var created string
if err := rows.Scan(&t.Hash, &t.User, &t.Device, &created); err != nil {
rows.Close()
return nil, nil, nil, err
}
t.Created = tdec(created)
tokens = append(tokens, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, nil, err
}
var policy *authPolicy
var rv, ra int
err = r.s.db.QueryRow(`SELECT require_verification, require_approval FROM auth_policy WHERE id = 1`).Scan(&rv, &ra)
if err == nil {
policy = &authPolicy{RequireVerification: rv != 0, RequireApproval: ra != 0}
} else if err != sql.ErrNoRows {
return nil, nil, nil, err
}
return users, tokens, policy, nil
}
func (r *sqlAccountRepo) PutAccount(u *authUser) error {
return r.s.exec(`INSERT INTO accounts (id,email,name,pass,status,created) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET email=excluded.email, name=excluded.name, pass=excluded.pass,
status=excluded.status, created=excluded.created`,
u.ID, u.Email, u.Name, u.Pass, u.Status, tenc(u.Created))
}
func (r *sqlAccountRepo) DeleteAccount(id string) error {
return r.s.exec(`DELETE FROM accounts WHERE id = ?`, id)
}
func (r *sqlAccountRepo) PutToken(t authToken) error {
return r.s.exec(`INSERT INTO tokens (hash,user_id,device,created) VALUES (?,?,?,?)
ON CONFLICT(hash) DO UPDATE SET user_id=excluded.user_id, device=excluded.device, created=excluded.created`,
t.Hash, t.User, t.Device, tenc(t.Created))
}
func (r *sqlAccountRepo) DeleteToken(hash string) error {
return r.s.exec(`DELETE FROM tokens WHERE hash = ?`, hash)
}
func (r *sqlAccountRepo) PutPolicy(p authPolicy) error {
return r.s.exec(`INSERT INTO auth_policy (id,require_verification,require_approval) VALUES (1,?,?)
ON CONFLICT(id) DO UPDATE SET require_verification=excluded.require_verification,
require_approval=excluded.require_approval`,
b2i(p.RequireVerification), b2i(p.RequireApproval))
}
// ---- projects ----
type sqlProjectRepo struct{ s *sqlMetaStore }
func (r *sqlProjectRepo) Load() ([]Project, error) {
rows, err := r.s.db.Query(`SELECT id, name, org, created, description, icon FROM projects`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Project
for rows.Next() {
var p Project
var created string
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created, &p.Description, &p.Icon); err != nil {
return nil, err
}
p.Created = tdec(created)
out = append(out, p)
}
return out, rows.Err()
}
func (r *sqlProjectRepo) Put(p Project) error {
return r.s.exec(`INSERT INTO projects (id,name,org,created,description,icon) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
description=excluded.description, icon=excluded.icon`,
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon)
}
func (r *sqlProjectRepo) Delete(id string) error {
return r.s.exec(`DELETE FROM projects WHERE id = ?`, id)
}
// ---- orgs (+ members, + invites) ----
type sqlOrgRepo struct{ s *sqlMetaStore }
func (r *sqlOrgRepo) Load() ([]Org, []OrgInvite, error) {
orgs := map[string]*Org{}
var order []string
rows, err := r.s.db.Query(`SELECT id, name, created FROM orgs`)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var o Org
var created string
if err := rows.Scan(&o.ID, &o.Name, &created); err != nil {
rows.Close()
return nil, nil, err
}
o.Created = tdec(created)
o.Members = map[string]string{}
orgs[o.ID] = &o
order = append(order, o.ID)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, err
}
rows, err = r.s.db.Query(`SELECT org, email, role FROM org_members`)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var org, email, role string
if err := rows.Scan(&org, &email, &role); err != nil {
rows.Close()
return nil, nil, err
}
if o := orgs[org]; o != nil {
o.Members[email] = role
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, err
}
outOrgs := make([]Org, 0, len(order))
for _, id := range order {
outOrgs = append(outOrgs, *orgs[id])
}
var invites []OrgInvite
rows, err = r.s.db.Query(`SELECT token, org, creator, created, expires, uses FROM invites`)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var i OrgInvite
var created, expires string
if err := rows.Scan(&i.Token, &i.Org, &i.Creator, &created, &expires, &i.Uses); err != nil {
rows.Close()
return nil, nil, err
}
i.Created, i.Expires = tdec(created), tdec(expires)
invites = append(invites, i)
}
rows.Close()
return outOrgs, invites, rows.Err()
}
func (r *sqlOrgRepo) PutOrg(o Org) error {
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(r.s.q(`INSERT INTO orgs (id,name,created) VALUES (?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, created=excluded.created`),
o.ID, o.Name, tenc(o.Created)); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM org_members WHERE org = ?`), o.ID); err != nil {
return err
}
for email, role := range o.Members {
if _, err := tx.Exec(r.s.q(`INSERT INTO org_members (org,email,role) VALUES (?,?,?)`),
o.ID, email, role); err != nil {
return err
}
}
return tx.Commit()
}
func (r *sqlOrgRepo) DeleteOrg(id string) error {
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(r.s.q(`DELETE FROM org_members WHERE org = ?`), id); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM orgs WHERE id = ?`), id); err != nil {
return err
}
return tx.Commit()
}
func (r *sqlOrgRepo) PutInvite(i OrgInvite) error {
return r.s.exec(`INSERT INTO invites (token,org,creator,created,expires,uses) VALUES (?,?,?,?,?,?)
ON CONFLICT(token) DO UPDATE SET org=excluded.org, creator=excluded.creator,
created=excluded.created, expires=excluded.expires, uses=excluded.uses`,
i.Token, i.Org, i.Creator, tenc(i.Created), tenc(i.Expires), i.Uses)
}
func (r *sqlOrgRepo) DeleteInvite(token string) error {
return r.s.exec(`DELETE FROM invites WHERE token = ?`, token)
}
// ---- shares ----
type sqlShareRepo struct{ s *sqlMetaStore }
func (r *sqlShareRepo) Load() ([]Share, error) {
rows, err := r.s.db.Query(`SELECT token, project, path, creator, created, expires FROM shares`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Share
for rows.Next() {
var s Share
var created, expires string
if err := rows.Scan(&s.Token, &s.Project, &s.Path, &s.Creator, &created, &expires); err != nil {
return nil, err
}
s.Created, s.Expires = tdec(created), tdec(expires)
out = append(out, s)
}
return out, rows.Err()
}
func (r *sqlShareRepo) Put(s Share) error {
return r.s.exec(`INSERT INTO shares (token,project,path,creator,created,expires) VALUES (?,?,?,?,?,?)
ON CONFLICT(token) DO UPDATE SET project=excluded.project, path=excluded.path,
creator=excluded.creator, created=excluded.created, expires=excluded.expires`,
s.Token, s.Project, s.Path, s.Creator, tenc(s.Created), tenc(s.Expires))
}
func (r *sqlShareRepo) Delete(token string) error {
return r.s.exec(`DELETE FROM shares WHERE token = ?`, token)
}
// ---- devices ----
type sqlDeviceRepo struct{ s *sqlMetaStore }
func (r *sqlDeviceRepo) Load() ([]DeviceInfo, error) {
rows, err := r.s.db.Query(`SELECT id, name, os, user_email, ip, last_seen FROM devices`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DeviceInfo
for rows.Next() {
var d DeviceInfo
var lastSeen string
if err := rows.Scan(&d.ID, &d.Name, &d.OS, &d.User, &d.IP, &lastSeen); err != nil {
return nil, err
}
d.LastSeen = tdec(lastSeen)
out = append(out, d)
}
return out, rows.Err()
}
func (r *sqlDeviceRepo) Put(d DeviceInfo) error {
return r.s.exec(`INSERT INTO devices (id,name,os,user_email,ip,last_seen) VALUES (?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, os=excluded.os, user_email=excluded.user_email,
ip=excluded.ip, last_seen=excluded.last_seen`,
d.ID, d.Name, d.OS, d.User, d.IP, tenc(d.LastSeen))
}
// ---- reads ----
type sqlReadRepo struct{ s *sqlMetaStore }
func (r *sqlReadRepo) Load() ([]ReadStat, error) {
rows, err := r.s.db.Query(`SELECT project, path, day, kind, actor, count, last FROM read_stats`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReadStat
for rows.Next() {
var st ReadStat
var last string
if err := rows.Scan(&st.Project, &st.Path, &st.Day, &st.Kind, &st.Actor, &st.Count, &last); err != nil {
return nil, err
}
st.Last = tdec(last)
out = append(out, st)
}
return out, rows.Err()
}
func (r *sqlReadRepo) PutBatch(stats []ReadStat) error {
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, st := range stats {
if _, err := tx.Exec(r.s.q(`INSERT INTO read_stats (project,path,day,kind,actor,count,last)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(project,path,day,kind,actor) DO UPDATE SET count=excluded.count, last=excluded.last`),
st.Project, st.Path, st.Day, st.Kind, st.Actor, st.Count, tenc(st.Last)); err != nil {
return err
}
}
return tx.Commit()
}
func (r *sqlReadRepo) DeleteBatch(keys []ReadStatKey) error {
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, k := range keys {
if _, err := tx.Exec(r.s.q(`DELETE FROM read_stats
WHERE project = ? AND path = ? AND day = ? AND kind = ? AND actor = ?`),
k.Project, k.Path, k.Day, k.Kind, k.Actor); err != nil {
return err
}
}
return tx.Commit()
}