mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
A teammate's agent writes a decision into runbook.md, it lands in your folder in fifteen seconds, and nothing tells you. Two halves of the fix: post_sync's payload gains `user` and `note` per changed entry, so a local recipe can post "Dana's Claude updated shared/findings/eu-checkout.md" instead of "1 file changed" — the copy was blocked by the payload, not by formatting. journal.LastOps is what makes it possible: Replay drops deleted paths and FileState carries no author, so deletes had nothing to attribute to. Less, Replay and FileState are untouched. And a hub-side webhook: one optional Webhook on Project, admin-set, https on Slack/Teams hosts only, never returned by any API. It fires AFTER the response from both places an op is born — handleStorePut and RemoteSource.OnCommit at the end of appendOps, which covers uploads, removes, restores and undo-run in one field. Goroutine, bounded client, log once, no retry queue: a slow Slack must never 502 a push, because the client retries a 502 and the retry re-fires the webhook. A project with no webhook set produces zero new behavior and zero new outbound requests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
981 lines
33 KiB
Go
981 lines
33 KiB
Go
package webapp
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strconv"
|
|
"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
|
|
ver int // schema version the store recorded before this process migrated
|
|
projects *sqlProjectRepo
|
|
orgs *sqlOrgRepo
|
|
shares *sqlShareRepo
|
|
devices *sqlDeviceRepo
|
|
reads *sqlReadRepo
|
|
sessions *sqlSessionReadRepo
|
|
}
|
|
|
|
// 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, w: regWriter{s, regAccounts}}
|
|
s.projects = &sqlProjectRepo{s: s, w: regWriter{s, regProjects}}
|
|
s.orgs = &sqlOrgRepo{s: s, w: regWriter{s, regOrgs}}
|
|
s.shares = &sqlShareRepo{s: s, w: regWriter{s, regShares}}
|
|
s.devices = &sqlDeviceRepo{s: s, w: regWriter{s, regDevices}}
|
|
s.reads = &sqlReadRepo{s: s, w: regWriter{s, regReads}}
|
|
s.sessions = &sqlSessionReadRepo{s: 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) SessionReads() SessionReadRepo { return s.sessions }
|
|
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
|
|
}
|
|
|
|
// Registry names in meta_version. One per service registry, matching what a
|
|
// single refresh() reloads — so orgs, members and invites share one counter,
|
|
// because OrgDB.refresh reloads all three together.
|
|
const (
|
|
regAccounts = "accounts"
|
|
regProjects = "projects"
|
|
regOrgs = "orgs"
|
|
regShares = "shares"
|
|
regDevices = "devices"
|
|
regReads = "reads"
|
|
)
|
|
|
|
// inTx runs fn in a transaction that also bumps reg's change token. Same
|
|
// transaction on purpose: a token that has not moved must mean no write
|
|
// landed, or refresh() would skip a reload it needed. The upsert-increment is
|
|
// portable — both engines take excluded.* in DO UPDATE.
|
|
func (s *sqlMetaStore) inTx(reg string, fn func(*sql.Tx) error) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if err := fn(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(s.q(`INSERT INTO meta_version (name,version) VALUES (?,1)
|
|
ON CONFLICT(name) DO UPDATE SET version = meta_version.version + 1`), reg); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// regWriter is one registry's write handle: sqlMetaStore.exec plus that
|
|
// registry's change-token bump, in one transaction. Repos hold one rather than
|
|
// passing the registry name at each call, so the QUERY stays argument zero and
|
|
// TestSec_DB_QueryRewriteOnlyEverSeesStaticSQL keeps checking every call site
|
|
// the way it already checks exec's.
|
|
type regWriter struct {
|
|
s *sqlMetaStore
|
|
reg string
|
|
}
|
|
|
|
func (w regWriter) exec(query string, args ...any) error {
|
|
return w.s.inTx(w.reg, func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(w.s.q(query), args...)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// version reads a registry's change token. A store that predates the table has
|
|
// no row for it, which is version zero — correct, since no write has happened
|
|
// through a binary that counts.
|
|
func (s *sqlMetaStore) version(reg string) (string, error) {
|
|
var v int64
|
|
switch err := s.db.QueryRow(s.q(`SELECT version FROM meta_version WHERE name = ?`), reg).Scan(&v); {
|
|
case err == sql.ErrNoRows:
|
|
return "0", nil
|
|
case err != nil:
|
|
return "", err
|
|
}
|
|
return strconv.FormatInt(v, 10), nil
|
|
}
|
|
|
|
// 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 '')`,
|
|
// device_rows replaces the original `devices` table, whose primary key
|
|
// was the id alone: two accounts naming one device id collapsed into a
|
|
// single row, so a restart handed the device to whoever wrote last and
|
|
// the hub then refused its real owner. The key is (user_email, id),
|
|
// matching DeviceRegistry. The old table is left in place — it is a
|
|
// display cache that every device rebuilds on its next sync cycle, and
|
|
// the rows worth keeping are copied over once below.
|
|
`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 device_rows (
|
|
user_email TEXT NOT NULL DEFAULT '', id TEXT NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '', os TEXT NOT NULL DEFAULT '',
|
|
ip TEXT NOT NULL DEFAULT '', first_seen TEXT NOT NULL DEFAULT '',
|
|
last_seen TEXT NOT NULL DEFAULT '', PRIMARY KEY (user_email, id))`,
|
|
// The WHERE is not redundant: SQLite cannot parse ON CONFLICT directly
|
|
// after an INSERT…SELECT (it reads as the SELECT's own ON clause), and
|
|
// Postgres accepts it either way.
|
|
`INSERT INTO device_rows (user_email,id,name,os,ip,first_seen,last_seen)
|
|
SELECT user_email,id,name,os,ip,last_seen,last_seen FROM devices
|
|
WHERE 1=1 ON CONFLICT (user_email,id) DO NOTHING`,
|
|
`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))`,
|
|
// Which paths one agent session read. Its own table, NOT a column on
|
|
// read_stats: read_stats is loaded whole into ReadLedger's map at boot
|
|
// and linearly scanned on every heat request, so session cardinality
|
|
// there would cost every project on the hub. Queried by primary key
|
|
// prefix, pruned by date — never loaded whole.
|
|
`CREATE TABLE IF NOT EXISTS read_sessions (
|
|
project TEXT NOT NULL, session TEXT NOT NULL, device TEXT NOT NULL,
|
|
path TEXT NOT NULL, last TEXT NOT NULL DEFAULT '',
|
|
PRIMARY KEY (project, session, device, path))`,
|
|
`CREATE INDEX IF NOT EXISTS read_sessions_last ON read_sessions (last)`,
|
|
`CREATE TABLE IF NOT EXISTS project_perms (
|
|
project TEXT NOT NULL, email TEXT NOT NULL, level TEXT NOT NULL,
|
|
PRIMARY KEY (project, email))`,
|
|
`CREATE TABLE IF NOT EXISTS schema_meta (
|
|
key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`,
|
|
// One counter per registry, bumped inside every write transaction, so
|
|
// refresh() can ask "has anything moved?" with one primary-key lookup
|
|
// instead of re-running the registry's whole (unfiltered, multi-table)
|
|
// Load on every authenticated request. See Versioned.
|
|
`CREATE TABLE IF NOT EXISTS meta_version (
|
|
name TEXT PRIMARY KEY, version BIGINT NOT NULL DEFAULT 0)`,
|
|
}
|
|
for _, st := range stmts {
|
|
if _, err := s.db.Exec(st); err != nil {
|
|
return fmt.Errorf("migrate: %w", err)
|
|
}
|
|
}
|
|
// The schema version this store last recorded, read before any ALTER: it is
|
|
// what tells a first-ever migration apart from a rollback. See addColumns.
|
|
var err error
|
|
if s.ver, err = s.readSchemaVersion(); err != nil {
|
|
return 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.
|
|
//
|
|
// `default_level` is GUARDED. It defaults to '' when added, and '' is READ
|
|
// AS `write` (Project.level — a deliberate no-migration choice, safe
|
|
// forward and fail-OPEN backward). So re-adding it to a table that already
|
|
// holds projects re-opens every `none` and `read` project to its whole
|
|
// organization, silently, and a rollback, an older dump or a half-applied
|
|
// migration is exactly how the column goes missing again.
|
|
if err := s.addColumns("projects", map[string]string{
|
|
"description": `TEXT NOT NULL DEFAULT ''`,
|
|
"icon": `TEXT NOT NULL DEFAULT ''`,
|
|
"creator": `TEXT NOT NULL DEFAULT ''`,
|
|
"default_level": `TEXT NOT NULL DEFAULT ''`,
|
|
"template": `TEXT NOT NULL DEFAULT ''`,
|
|
// Unguarded on purpose, unlike default_level above: empty means "no
|
|
// webhook", so a rollback or a half-applied migration fails CLOSED —
|
|
// the project stops notifying. The opposite of the default_level
|
|
// hazard, and the safe direction for an outbound credential.
|
|
"webhook": `TEXT NOT NULL DEFAULT ''`,
|
|
}, map[string]string{
|
|
"default_level": "it silently re-opens every restricted project to its whole organization",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
// A member row's join time. Rows written before it carry '' — the zero
|
|
// time — which is what makes them the longest-standing members there are,
|
|
// and that is the right answer for rows that predate the column.
|
|
if err := s.addColumns("org_members", map[string]string{
|
|
"joined": `TEXT NOT NULL DEFAULT ''`,
|
|
}, map[string]string{}); err != nil {
|
|
return err
|
|
}
|
|
return s.exec(`INSERT INTO schema_meta (key,value) VALUES (?,?)
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, "version", strconv.Itoa(schemaVersion))
|
|
}
|
|
|
|
// schemaVersion is what THIS binary's migration produces. Bump it when adding
|
|
// a column whose DEFAULT cannot be told apart from a real value — see the
|
|
// guarded set in migrate.
|
|
const schemaVersion = 1
|
|
|
|
// readSchemaVersion reads the version the store last recorded. 0 means a store
|
|
// written before versioning existed, which is the ordinary upgrade path and the
|
|
// one case where adding a guarded column is correct.
|
|
func (s *sqlMetaStore) readSchemaVersion() (int, error) {
|
|
var v string
|
|
err := s.db.QueryRow(s.q(`SELECT value FROM schema_meta WHERE key = ?`), "version").Scan(&v)
|
|
if err == sql.ErrNoRows {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("migrate: read schema version: %w", err)
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("migrate: schema version %q is not a number", v)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// 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, guarded 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
|
|
}
|
|
// A guarded column's DEFAULT is indistinguishable from a real value, so
|
|
// adding it rewrites the meaning of every row already in the table.
|
|
// That is correct exactly once — the first migration, before this store
|
|
// ever recorded a version — and on an empty table, where there is no
|
|
// meaning to rewrite. Anywhere else it is a rollback or an older dump,
|
|
// and it fails closed and loudly instead of widening access in silence.
|
|
if why := guarded[col]; why != "" && s.ver > 0 {
|
|
var rows int
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&rows); err != nil {
|
|
return fmt.Errorf("migrate %s: %w", table, err)
|
|
}
|
|
if rows > 0 {
|
|
return fmt.Errorf("this database records schema version %d but %s.%s is missing "+
|
|
"while the table holds %d row(s): that is a rollback, an older dump, or a "+
|
|
"half-applied migration, and re-adding the column would hand every row its "+
|
|
"DEFAULT — %s. Restore a dump taken at schema version %d or later, or add "+
|
|
"the column back by hand with the values it should hold",
|
|
s.ver, table, col, rows, why, s.ver)
|
|
}
|
|
}
|
|
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
|
|
w regWriter
|
|
}
|
|
|
|
func (r *sqlAccountRepo) Version() (string, error) { return r.s.version(regAccounts) }
|
|
|
|
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 {
|
|
if err := checkAccount(u); err != nil {
|
|
return err
|
|
}
|
|
// The update arm is scoped to the SAME account: an id belongs to one
|
|
// address for the life of the hub, so a row arriving under a live id with
|
|
// a different email is a collision, not an update, and applying it hands
|
|
// the victim's device tokens and memberships to the newcomer while their
|
|
// password hash disappears. WHERE makes it a no-op; the rowcount check
|
|
// turns that into an error the caller sees.
|
|
return r.s.inTx(regAccounts, func(tx *sql.Tx) error {
|
|
res, err := tx.Exec(r.s.q(`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
|
|
WHERE lower(accounts.email) = lower(excluded.email)`),
|
|
u.ID, u.Email, u.Name, u.Pass, u.Status, tenc(u.Created))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
|
return fmt.Errorf("account id %s already belongs to another account", u.ID)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (r *sqlAccountRepo) DeleteAccount(id string) error {
|
|
return r.w.exec(`DELETE FROM accounts WHERE id = ?`, id)
|
|
}
|
|
|
|
func (r *sqlAccountRepo) PutToken(t authToken) error {
|
|
if err := checkToken(t); err != nil {
|
|
return err
|
|
}
|
|
return r.w.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.w.exec(`DELETE FROM tokens WHERE hash = ?`, hash)
|
|
}
|
|
|
|
func (r *sqlAccountRepo) PutPolicy(p authPolicy) error {
|
|
return r.w.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
|
|
w regWriter
|
|
}
|
|
|
|
func (r *sqlProjectRepo) Version() (string, error) { return r.s.version(regProjects) }
|
|
|
|
func (r *sqlProjectRepo) Load() ([]Project, error) {
|
|
rows, err := r.s.db.Query(
|
|
`SELECT id, name, org, created, description, icon, creator, default_level, template, webhook FROM projects`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byID := map[string]*Project{}
|
|
var order []string
|
|
for rows.Next() {
|
|
var p Project
|
|
var created string
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created,
|
|
&p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template, &p.Webhook); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
p.Created = tdec(created)
|
|
byID[p.ID] = &p
|
|
order = append(order, p.ID)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err = r.s.db.Query(`SELECT project, email, level FROM project_perms`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var project, email, level string
|
|
if err := rows.Scan(&project, &email, &level); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
if p := byID[project]; p != nil {
|
|
if p.Perms == nil {
|
|
p.Perms = map[string]string{}
|
|
}
|
|
p.Perms[email] = level
|
|
}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]Project, 0, len(order))
|
|
for _, id := range order {
|
|
out = append(out, *byID[id])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Put writes the project and replaces its grants in one transaction — same
|
|
// shape as PutOrg over orgs/org_members.
|
|
func (r *sqlProjectRepo) Put(p Project) error {
|
|
if err := checkProject(p); err != nil {
|
|
return err
|
|
}
|
|
return r.s.inTx(regProjects, func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(r.s.q(
|
|
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,webhook)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
|
|
description=excluded.description, icon=excluded.icon,
|
|
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template,
|
|
webhook=excluded.webhook`),
|
|
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template, p.Webhook); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), p.ID); err != nil {
|
|
return err
|
|
}
|
|
for email, level := range p.Perms {
|
|
if _, err := tx.Exec(r.s.q(`INSERT INTO project_perms (project,email,level) VALUES (?,?,?)`),
|
|
p.ID, email, level); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// PutMeta writes the project's own columns and does not touch project_perms —
|
|
// see rowScopedProjectRepo for why a metadata write must not carry a grant set.
|
|
func (r *sqlProjectRepo) PutMeta(p Project) error {
|
|
if err := checkProject(p); err != nil {
|
|
return err
|
|
}
|
|
return r.w.exec(`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,webhook)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
|
|
description=excluded.description, icon=excluded.icon,
|
|
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template,
|
|
webhook=excluded.webhook`,
|
|
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template, p.Webhook)
|
|
}
|
|
|
|
// PutPerm writes one grant row. An empty level removes it.
|
|
func (r *sqlProjectRepo) PutPerm(project, email, level string) error {
|
|
if err := storable(project, email, level); err != nil {
|
|
return err
|
|
}
|
|
if level == "" {
|
|
return r.w.exec(`DELETE FROM project_perms WHERE project = ? AND email = ?`, project, email)
|
|
}
|
|
return r.w.exec(`INSERT INTO project_perms (project,email,level) VALUES (?,?,?)
|
|
ON CONFLICT(project,email) DO UPDATE SET level=excluded.level`, project, email, level)
|
|
}
|
|
|
|
func (r *sqlProjectRepo) Delete(id string) error {
|
|
return r.s.inTx(regProjects, func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.Exec(r.s.q(`DELETE FROM projects WHERE id = ?`), id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// ---- orgs (+ members, + invites) ----
|
|
|
|
type sqlOrgRepo struct {
|
|
s *sqlMetaStore
|
|
w regWriter
|
|
}
|
|
|
|
func (r *sqlOrgRepo) Version() (string, error) { return r.s.version(regOrgs) }
|
|
|
|
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{}
|
|
o.Joined = map[string]time.Time{}
|
|
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, joined FROM org_members`)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for rows.Next() {
|
|
var org, email, role, joined string
|
|
if err := rows.Scan(&org, &email, &role, &joined); err != nil {
|
|
rows.Close()
|
|
return nil, nil, err
|
|
}
|
|
if o := orgs[org]; o != nil {
|
|
o.Members[email] = role
|
|
o.Joined[email] = tdec(joined)
|
|
}
|
|
}
|
|
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 {
|
|
if err := checkOrg(o); err != nil {
|
|
return err
|
|
}
|
|
return r.s.inTx(regOrgs, func(tx *sql.Tx) error {
|
|
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,joined) VALUES (?,?,?,?)`),
|
|
o.ID, email, role, tenc(o.Joined[email])); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// PutOrgMeta writes the org's own columns and does not touch org_members —
|
|
// see rowScopedOrgRepo for why a rename must not carry a member set.
|
|
func (r *sqlOrgRepo) PutOrgMeta(o Org) error {
|
|
if err := checkOrg(o); err != nil {
|
|
return err
|
|
}
|
|
return r.w.exec(`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))
|
|
}
|
|
|
|
// PutMember writes one membership row. An empty role removes it.
|
|
func (r *sqlOrgRepo) PutMember(org, email, role string, joined time.Time) error {
|
|
if err := storable(org, email, role); err != nil {
|
|
return err
|
|
}
|
|
if role == "" {
|
|
return r.w.exec(`DELETE FROM org_members WHERE org = ? AND email = ?`, org, email)
|
|
}
|
|
return r.w.exec(`INSERT INTO org_members (org,email,role,joined) VALUES (?,?,?,?)
|
|
ON CONFLICT(org,email) DO UPDATE SET role=excluded.role, joined=excluded.joined`,
|
|
org, email, role, tenc(joined))
|
|
}
|
|
|
|
func (r *sqlOrgRepo) DeleteOrg(id string) error {
|
|
return r.s.inTx(regOrgs, func(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(r.s.q(`DELETE FROM org_members WHERE org = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.Exec(r.s.q(`DELETE FROM orgs WHERE id = ?`), id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (r *sqlOrgRepo) PutInvite(i OrgInvite) error {
|
|
if err := checkInvite(i); err != nil {
|
|
return err
|
|
}
|
|
return r.w.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.w.exec(`DELETE FROM invites WHERE token = ?`, token)
|
|
}
|
|
|
|
// ---- shares ----
|
|
|
|
type sqlShareRepo struct {
|
|
s *sqlMetaStore
|
|
w regWriter
|
|
}
|
|
|
|
func (r *sqlShareRepo) Version() (string, error) { return r.s.version(regShares) }
|
|
|
|
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 {
|
|
if err := checkShare(s); err != nil {
|
|
return err
|
|
}
|
|
return r.w.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.w.exec(`DELETE FROM shares WHERE token = ?`, token)
|
|
}
|
|
|
|
// ---- devices ----
|
|
|
|
type sqlDeviceRepo struct {
|
|
s *sqlMetaStore
|
|
w regWriter
|
|
}
|
|
|
|
func (r *sqlDeviceRepo) Version() (string, error) { return r.s.version(regDevices) }
|
|
|
|
func (r *sqlDeviceRepo) Load() ([]DeviceInfo, error) {
|
|
rows, err := r.s.db.Query(`SELECT id, name, os, user_email, ip, first_seen, last_seen FROM device_rows`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []DeviceInfo
|
|
for rows.Next() {
|
|
var d DeviceInfo
|
|
var firstSeen, lastSeen string
|
|
if err := rows.Scan(&d.ID, &d.Name, &d.OS, &d.User, &d.IP, &firstSeen, &lastSeen); err != nil {
|
|
return nil, err
|
|
}
|
|
d.FirstSeen, d.LastSeen = tdec(firstSeen), tdec(lastSeen)
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *sqlDeviceRepo) Put(d DeviceInfo) error {
|
|
if err := checkDevice(d); err != nil {
|
|
return err
|
|
}
|
|
return r.w.exec(`INSERT INTO device_rows (user_email,id,name,os,ip,first_seen,last_seen) VALUES (?,?,?,?,?,?,?)
|
|
ON CONFLICT(user_email,id) DO UPDATE SET name=excluded.name, os=excluded.os,
|
|
ip=excluded.ip, last_seen=excluded.last_seen`,
|
|
d.User, d.ID, d.Name, d.OS, d.IP, tenc(d.FirstSeen), tenc(d.LastSeen))
|
|
}
|
|
|
|
func (r *sqlDeviceRepo) Delete(user, id string) error {
|
|
return r.w.exec(`DELETE FROM device_rows WHERE user_email = ? AND id = ?`, user, id)
|
|
}
|
|
|
|
// ---- reads ----
|
|
|
|
type sqlReadRepo struct {
|
|
s *sqlMetaStore
|
|
w regWriter
|
|
}
|
|
|
|
func (r *sqlReadRepo) Version() (string, error) { return r.s.version(regReads) }
|
|
|
|
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 {
|
|
for _, s := range stats {
|
|
if err := checkReadStat(s); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return r.s.inTx(regReads, func(tx *sql.Tx) error {
|
|
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 nil
|
|
})
|
|
}
|
|
|
|
func (r *sqlReadRepo) DeleteBatch(keys []ReadStatKey) error {
|
|
return r.s.inTx(regReads, func(tx *sql.Tx) error {
|
|
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 nil
|
|
})
|
|
}
|
|
|
|
// ---- session reads ----
|
|
|
|
// No regWriter: these rows are telemetry detail, never read through a
|
|
// registry's refresh path, so there is no version counter to bump.
|
|
type sqlSessionReadRepo struct {
|
|
s *sqlMetaStore
|
|
}
|
|
|
|
func (r *sqlSessionReadRepo) PutBatch(reads []SessionRead) error {
|
|
for _, sr := range reads {
|
|
if err := checkSessionRead(sr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
tx, err := r.s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for _, sr := range reads {
|
|
if _, err := tx.Exec(r.s.q(`INSERT INTO read_sessions (project,session,device,path,last)
|
|
VALUES (?,?,?,?,?)
|
|
ON CONFLICT(project,session,device,path) DO UPDATE SET last=excluded.last`),
|
|
sr.Project, sr.Session, sr.Device, sr.Path, tenc(sr.Last)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (r *sqlSessionReadRepo) ListBySession(project, session, device string) ([]SessionRead, error) {
|
|
rows, err := r.s.db.Query(r.s.q(`SELECT project, session, device, path, last FROM read_sessions
|
|
WHERE project = ? AND session = ? AND device = ? ORDER BY path`), project, session, device)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []SessionRead
|
|
for rows.Next() {
|
|
var sr SessionRead
|
|
var last string
|
|
if err := rows.Scan(&sr.Project, &sr.Session, &sr.Device, &sr.Path, &last); err != nil {
|
|
return nil, err
|
|
}
|
|
sr.Last = tdec(last)
|
|
out = append(out, sr)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *sqlSessionReadRepo) PruneBefore(t time.Time) error {
|
|
_, err := r.s.db.Exec(r.s.q(`DELETE FROM read_sessions WHERE last < ?`), tenc(t))
|
|
return err
|
|
}
|