mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* feat(templates): start a project from a structure, not an empty folder A new project was an empty folder with a .bdriveignore in it, so every agent session invented its own layout and the folder rotted into a pile. Both surfaces now offer the same three starting points — from a template, from scratch, from an existing folder (which is just a non-empty folder, and is never restructured). internal/templates holds the shipped set as literal go:embed'ed files: `docs` (docs/, decisions/) and `para` (projects/, areas/, resources/, archives/). cmd/bdrive is one binary for the CLI and the hub, so both read the identical set — no gallery, no drift. The AGENTS.md in each is the deliverable: where a new note goes, when something is archived, what a good filename looks like. Every directory holds a real file, because BearDrive syncs paths and an empty directory would never reach a teammate. The hub seeds at creation through the existing Upload+Commit path, journaled under its own device, and records the choice on the project record — so a user who picked PARA in a browser sees PARA in the browser, and a later init cannot seed a second copy. `bdrive init --template <name>` goes through the same endpoint, with a local-seed fallback for a hub too old to know the field, and seeds in place when re-run in an already-initialized folder (the agent's post-init path). Seeding never overwrites an existing path, which is what makes a double-seed a no-op rather than a divergence. Refusals cost nothing: an unknown name and --template with --only are both rejected before any network call or write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cli): joining a project that already has a template is refused by name The one acceptance case with no test behind it: connecting to an existing project with --template must say what the project was actually created from, and must not write the other skeleton on the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(templates): name the docs template in plain English, not an acronym "Plain docs + ADRs" was the recommended, first, preselected-adjacent option in a picker that non-engineers see — and it's the label people accept without reading further, so half of it not parsing is the worst place for jargon. The title also disagreed with its own blurb: "ADRs" over "docs/, decisions/", two words for the same folder one line apart. Now "Docs + decision records", which says the same thing to everyone and matches the folder names. The term itself moves into decisions/0001-record-decisions.md, where the reader is already inside the structure and the file can teach it in passing. One line in the registry drives both the web dialog and the CLI menu; the rest is prose echoing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(templates): add the LLM wiki template The third starting point from the issue title, unblocked: the spec parked it because shipping an approximation under someone's name needed a source, and there is now one — Karpathy's LLM Wiki gist. Worth noting the issue's own one-line description of it ("few large, append-heavy topic pages") does not match the source, which is the opposite: many interlinked pages, where a single ingest touches 10-15 of them. The pattern is three layers and three operations, not a folder shape. sources/ is yours and immutable; wiki/ is the agent's and it owns every page; AGENTS.md is the schema layer — which is exactly the file this template system already treats as the deliverable, so the fit is direct. index.md and log.md ship as the two navigation files the pattern turns on. Three of the things the gist tells you to go set up, BearDrive already is: version history and collaboration (per-file history, bdrive log), an Obsidian- style reader for [[wikilinks]] (the hub viewer), and a surface for the lint pass (the dashboard is literally reads x staleness). Two rules in the AGENTS.md are load-bearing and deliberate. A page write that has not updated the index is an incomplete write — a stale index is worse than a missing page, because it is read first and believed. And with no sources yet, build nothing: the structure grows out of the material rather than ahead of it. Shipped second, not first: docs stays the recommendation because a default is the option chosen by people not reading closely, and this pattern degrades badly when half-followed. Promoting it later is one line in the registry. The shipped-template test now checks the "what happens when something stops being true" question through a set of alternatives — PARA archives, a wiki supersedes and revises — since the vocabulary honestly differs by structure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): "I already have a folder" as a starting point The third way to start from the spec — adopt what you already have — had no presence in the browser. Templates and "empty" were the only visible answers, so someone with a folder of notes either hesitated or picked a template and got four directories merged into their material. The constraint that shapes it: the browser cannot reach your disk, so this cannot change what is created. It creates the same empty project "Empty project" does; what it changes is the next screen. Create therefore stays enabled — disabling it would leave the dialog a dead end AND produce no project id, which is the one thing the paste prompt actually needs. Landing on the project home with the intent, three things differ: the guide says "in the folder you already have", a note states plainly that connecting never moves, renames or overwrites anything, and the paste prompt tells the agent a folder already exists. That last one is the part that isn't cosmetic — without it an agent reads an empty project and proposes creating shared/, the one recommendation that is wrong here. It still asks which folder: that is the runbook's hard gate and nothing here weakens it. The intent rides in the URL (?connect=existing) rather than onto the project record, the same way ?v= pins a file version. It belongs to whoever is connecting right now — a teammate who connects next week has their own answer and would be told the wrong thing by a persisted flag. Five rows made the dialog tall enough to push Create off a short viewport, so .modal scrolls internally. A hairline divider between the seeding and non-seeding rows was tried and removed: --border is 7% white, which at 1px in a gap renders as literally nothing. The gap is the cue that reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): with no projects, open the create dialog and give the page a way in A signed-in account with no projects landed on a page whose only path forward was pasting a prompt into a coding agent. Now the create dialog opens itself — with nothing to browse there is nothing else on that page to do — and the page behind it leads with "Start a project" and a button, so closing the dialog is not a dead end. The dialog moves up to HubApp because three things ask for it now: the sidebar's +, the empty state's button, and the auto-open. ProjectNav keeps only an onNew callback; one owner beats three copies of the create handler. Two guards on the auto-open. It fires once per mount, keyed off a ref rather than the empty state, or closing it would immediately reopen it. And it never fires on a read-only hub, which refuses creation server-side with a 403 — opening a dialog that cannot succeed is worse than the page it covers. The agent paste-prompt stays, demoted to "Or let your agent do it": it is still the right path for someone who wants the folder connected in the same breath, and it is the only path on a hub where this account cannot create. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
638 lines
19 KiB
Go
638 lines
19 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))`,
|
|
`CREATE TABLE IF NOT EXISTS project_perms (
|
|
project TEXT NOT NULL, email TEXT NOT NULL, level TEXT NOT NULL,
|
|
PRIMARY KEY (project, email))`,
|
|
}
|
|
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 ''`,
|
|
"creator": `TEXT NOT NULL DEFAULT ''`,
|
|
"default_level": `TEXT NOT NULL DEFAULT ''`,
|
|
"template": `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, creator, default_level, template 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); 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 {
|
|
tx, err := r.s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(r.s.q(
|
|
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template)
|
|
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`),
|
|
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template); 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 tx.Commit()
|
|
}
|
|
|
|
func (r *sqlProjectRepo) Delete(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 project_perms WHERE project = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(r.s.q(`DELETE FROM projects WHERE id = ?`), id); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ---- 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()
|
|
}
|