This commit is contained in:
@@ -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))`
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user