initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
package db
// PostgreSQL builds the camelCase JSON shape consumed by the existing UI.
// The password-bearing User and ImapConfig rows are never embedded.
//
// Review PRIV-02: bookmark projections are permission-specific. Permission to
// read a bookmark (direct share, shared tag, or shared date) does not imply
// permission to read the owner's address-book entry, enumerate the other
// recipients, or see the owner's operational metadata (alerts, mailbox watch,
// board placement). BookmarkSharedView is an allowlist: new columns are NOT
// exposed to shared readers without a deliberate addition there.
// BookmarkOwnerView is the full projection, for the owner only. It embeds the
// attached contact record and the recipient list with names and emails — the
// share panel renders those for the owner.
const BookmarkOwnerView = `to_jsonb(b) || jsonb_build_object(
'tags',COALESCE((SELECT jsonb_agg(to_jsonb(bt)||jsonb_build_object('tag',to_jsonb(t))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb),
'contact',(SELECT to_jsonb(c) FROM "Contact" c WHERE c.id=b."contactId"),
'sharedWith',COALESCE((SELECT jsonb_agg(to_jsonb(s)||jsonb_build_object('user',jsonb_build_object('id',u.id,'name',u.name,'email',u.email))) FROM "BookmarkShare" s JOIN "User" u ON u.id=s."userId" WHERE s."bookmarkId"=b.id),'[]'::jsonb),
'owner',(SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image) FROM "User" u WHERE u.id=b."ownerId"))`
// BookmarkSharedView is the narrowed projection for authenticated non-owner
// readers: the link itself, its presentation metadata, the content the owner
// attached for readers (description, notes, due date, tags), and enough owner
// identity to attribute the share inside the workspace. No contact record, no
// recipient list, no alerts, no mailbox-watch settings, no board placement.
const BookmarkSharedView = `jsonb_build_object(
'id',b.id,'url',b.url,'title',b.title,'description',b.description,'notes',b.notes,
'favicon',b.favicon,'ogImage',b."ogImage",'ogTitle',b."ogTitle",'ogDescription',b."ogDescription",
'dueDate',b."dueDate",'isPublic',b."isPublic",'kanbanStatus',b."kanbanStatus",
'ownerId',b."ownerId",'createdAt',b."createdAt",'updatedAt',b."updatedAt",
'tags',COALESCE((SELECT jsonb_agg(to_jsonb(bt)||jsonb_build_object('tag',to_jsonb(t))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb),
'owner',(SELECT jsonb_build_object('id',u.id,'name',u.name) FROM "User" u WHERE u.id=b."ownerId"))`
// BookmarkViewerView picks the projection by ownership. Like BookmarkVisible
// it binds the requesting user as $2, so it drops into the same queries.
const BookmarkViewerView = `CASE WHEN b."ownerId"=$2 THEN ` + BookmarkOwnerView + ` ELSE ` + BookmarkSharedView + ` END`
// BookmarkVisible binds the requesting user as $2. Tag sharing is group-wide
// by product decision: any recipient of a tag sees every bookmark carrying
// it, including bookmarks other recipients added. The share UI says so.
const BookmarkVisible = `(b."ownerId"=$2 OR EXISTS(SELECT 1 FROM "BookmarkShare" s WHERE s."bookmarkId"=b.id AND s."userId"=$2)
OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "TagShare" ts ON ts."tagId"=bt."tagId" WHERE bt."bookmarkId"=b.id AND ts."userId"=$2)
OR EXISTS(SELECT 1 FROM "SharedDate" sd WHERE sd."creatorId"=b."ownerId" AND sd."recipientId"=$2 AND (b."dueDate" AT TIME ZONE 'UTC')::date=sd.date))`
+153
View File
@@ -0,0 +1,153 @@
package db
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/andrew/go-computer/internal/config"
_ "github.com/jackc/pgx/v5/stdlib"
)
type DB struct{ *sql.DB }
type Object = map[string]any
var ErrNotFound = errors.New("not found")
func New(cfg *config.Config) (*DB, error) {
d, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return nil, err
}
d.SetMaxOpenConns(20)
d.SetMaxIdleConns(5)
d.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err = d.PingContext(ctx); err != nil {
d.Close()
return nil, fmt.Errorf("database: %w", err)
}
return &DB{d}, nil
}
func (d *DB) Close() error { return d.DB.Close() }
type contextKey struct{}
// FromContext returns the request-scoped database. A missing value is a
// wiring bug (route mounted without withDB) and must fail loudly here rather
// than nil-panic at the first query.
func FromContext(ctx context.Context) *DB {
d, ok := ctx.Value(contextKey{}).(*DB)
if !ok || d == nil {
panic("db: no database in request context (route missing withDB middleware)")
}
return d
}
func NewContext(ctx context.Context, d *DB) context.Context {
return context.WithValue(ctx, contextKey{}, d)
}
func NewID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
// Queryer is also implemented by sql.Tx, so mutations and relation updates
// can be committed atomically without a second connection pool.
type Queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func One(ctx context.Context, q Queryer, query string, args ...any) (Object, error) {
var raw []byte
err := q.QueryRowContext(ctx, query, args...).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
var v Object
err = json.Unmarshal(raw, &v)
return v, err
}
func Many(ctx context.Context, q Queryer, query string, args ...any) ([]Object, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Object{}
for rows.Next() {
var raw []byte
var v Object
if err = rows.Scan(&raw); err != nil {
return nil, err
}
if err = json.Unmarshal(raw, &v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func Quote(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
// Table and column names originate in server-owned allowlists, never request
// parameters. Every value is a PostgreSQL bind parameter.
func Insert(ctx context.Context, q Queryer, table string, data Object) (Object, error) {
keys := []string{}
for k := range data {
keys = append(keys, k)
}
sort.Strings(keys)
cols, ps := []string{}, []string{}
args := []any{}
for i, k := range keys {
cols = append(cols, Quote(k))
ps = append(ps, fmt.Sprintf("$%d", i+1))
args = append(args, data[k])
}
return One(ctx, q, `INSERT INTO `+Quote(table)+` (`+strings.Join(cols, ",")+`) VALUES (`+strings.Join(ps, ",")+`) RETURNING to_jsonb(`+Quote(table)+`)`, args...)
}
func Update(ctx context.Context, q Queryer, table, id, ownerCol, owner string, data Object) (Object, error) {
keys := []string{}
for k := range data {
keys = append(keys, k)
}
sort.Strings(keys)
sets, args := []string{}, []any{}
for i, k := range keys {
sets = append(sets, Quote(k)+fmt.Sprintf("=$%d", i+1))
args = append(args, data[k])
}
args = append(args, id, owner)
where := fmt.Sprintf(`id=$%d AND %s=$%d`, len(args)-1, Quote(ownerCol), len(args))
if len(sets) == 0 {
return One(ctx, q, `SELECT to_jsonb(t) FROM `+Quote(table)+` t WHERE `+where, args...)
}
return One(ctx, q, `UPDATE `+Quote(table)+` SET `+strings.Join(sets, ",")+` WHERE `+where+` RETURNING to_jsonb(`+Quote(table)+`)`, args...)
}
func Transaction(ctx context.Context, d *DB, fn func(*sql.Tx) error) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = fn(tx); err != nil {
return err
}
return tx.Commit()
}
+97
View File
@@ -0,0 +1,97 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/models"
)
type UserRepo struct{ db *DB }
func NewUserRepo(d *DB) *UserRepo { return &UserRepo{d} }
func (r *UserRepo) find(ctx context.Context, field, value string) (*models.User, error) {
o, err := One(ctx, r.db, `SELECT to_jsonb(u) FROM "User" u WHERE `+Quote(field)+`=$1`, value)
if err != nil {
return nil, err
}
raw, _ := json.Marshal(o)
var row struct {
ID string `json:"id"`
Name *string `json:"name"`
Email *string `json:"email"`
EmailVerified *string `json:"emailVerified"`
Image *string `json:"image"`
Password *string `json:"password"`
Role string `json:"role"`
TokenVersion int `json:"tokenVersion"`
}
if err = json.Unmarshal(raw, &row); err != nil {
return nil, err
}
return &models.User{ID: row.ID, Name: row.Name, Email: row.Email, EmailVerified: row.EmailVerified, Image: row.Image, Password: row.Password, Role: row.Role, TokenVersion: row.TokenVersion}, nil
}
func (r *UserRepo) FindByEmail(ctx context.Context, e string) (*models.User, error) {
return r.find(ctx, "email", e)
}
func (r *UserRepo) FindByID(ctx context.Context, id string) (*models.User, error) {
return r.find(ctx, "id", id)
}
func (r *UserRepo) SetRole(ctx context.Context, id, role string) error {
_, err := r.db.ExecContext(ctx, `UPDATE "User" SET role=$1,"updatedAt"=now() WHERE id=$2`, role, id)
return err
}
// BumpTokenVersion invalidates every cookie JWT issued for the user so far.
func (r *UserRepo) BumpTokenVersion(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `UPDATE "User" SET "tokenVersion"="tokenVersion"+1,"updatedAt"=now() WHERE id=$1`, id)
return err
}
type NewUserInput struct{ ID, Name, Email, Password, Role string }
// A transaction-scoped lock serializes first-admin selection with signup.
//
// Review AUTH-06: initial provisioning is a one-shot lifecycle event,
// distinct from administrator recovery. Once bootstrap completion is
// recorded in AppSetting, the mere absence of a qualifying administrator —
// e.g. after demoting or removing the last one — never re-opens
// administrator assignment through signup. Recovery is operator-driven
// (ADMIN_EMAILS + make seed, or promotion by an existing admin).
func (r *UserRepo) Create(ctx context.Context, u *NewUserInput) (string, error) {
err := Transaction(ctx, r.db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(927438)`); err != nil {
return err
}
var bootstrapDone bool
if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT value->>'completed' FROM "AppSetting" WHERE key='adminBootstrap'),'false')::boolean`).Scan(&bootstrapDone); err != nil {
return err
}
var admins int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); err != nil {
return err
}
role := u.Role
if role == "" {
role = "USER"
}
promote := config.AllowBootstrapSignup() && !bootstrapDone && admins == 0 && len(config.AdminEmails()) == 0 && !isTestEmail(u.Email) && role == "USER"
if promote {
role = "ADMIN"
}
// Record bootstrap completion as soon as the installation has (or was
// just given) a real administrator. A test-address signup with no
// admins present never claims the bootstrap.
if !bootstrapDone && (role == "ADMIN" || admins > 0) {
if _, err := tx.ExecContext(ctx, `INSERT INTO "AppSetting"(key,value) VALUES('adminBootstrap',jsonb_build_object('completed',true)) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,"updatedAt"=now()`); err != nil {
return err
}
}
_, err := Insert(ctx, tx, "User", Object{"id": u.ID, "name": u.Name, "email": u.Email, "password": u.Password, "role": role})
return err
})
return u.ID, err
}
func isTestEmail(e string) bool { return len(e) >= 12 && e[len(e)-12:] == "@example.com" }
func IsAdminEmail(email string) bool { return config.IsAdminEmail(email) }