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
+20
View File
@@ -0,0 +1,20 @@
package auth
type SigninRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type SignupRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
// SignupResponse is deliberately value-identical for every accepted
// registration attempt (review CR-03): a populated internal identifier used
// to distinguish a fresh account from a duplicate/reserved address, which is
// an unauthenticated existence oracle. Ownership is established by logging
// in; /api/me then reports the id.
type SignupResponse struct {
OK bool `json:"ok"`
}
+19
View File
@@ -0,0 +1,19 @@
package auth
import (
"golang.org/x/crypto/bcrypt"
)
// bcryptHash hashes a password with cost 12 (porting guide 3.1).
func bcryptHashImpl(password string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(h), nil
}
// bcryptCheck compares a password against a bcrypt hash.
func bcryptCheckImpl(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+263
View File
@@ -0,0 +1,263 @@
package auth
import (
"encoding/json"
"errors"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"github.com/jackc/pgx/v5/pgconn"
"log"
"net/http"
"net/mail"
"net/url"
"os"
"strings"
"time"
)
const bcryptCost = 12
// Signup field caps (review "cap input sizes"); the request-body limit alone
// is far too coarse for values copied into every user projection.
const (
maxSignupEmailLen = 254
maxSignupNameLen = 120
)
// dummyPasswordHash is bcrypt-cost-12 hash of a value no user can supply.
// Login compares against it for unknown accounts so timing does not reveal
// which emails are registered.
const dummyPasswordHash = "$2a$12$OeKCcVovKAAb7gKAhWqA..yGR3eoqGQAKzSrWwLryJgAro0Vc60WW"
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func failure(w http.ResponseWriter, status int, s string) {
writeJSON(w, status, map[string]string{"error": s})
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if json.NewDecoder(r.Body).Decode(v) != nil {
failure(w, 400, "Invalid JSON")
return false
}
return true
}
func HandleSignup(w http.ResponseWriter, r *http.Request) {
if !checkAuthRequest(w, r) {
return
}
if os.Getenv("SIGNUP_ENABLED") == "0" {
failure(w, 403, "Registration is disabled")
return
}
var req SignupRequest
if !decode(w, r, &req) {
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
if req.Email == "" || req.Password == "" {
failure(w, 400, "Email and password are required")
return
}
// Per-field caps (review "cap input sizes"): the 1 MiB body limit alone
// lets a name or address bloat every user projection and share picker.
if len(req.Email) > maxSignupEmailLen {
failure(w, 400, "Invalid email")
return
}
if len([]rune(req.Name)) > maxSignupNameLen {
failure(w, 400, "Name must be at most 120 characters")
return
}
if a, e := mail.ParseAddress(req.Email); e != nil || a.Address != req.Email {
failure(w, 400, "Invalid email")
return
}
if len(req.Password) < 8 {
failure(w, 400, "Password must be at least 8 characters")
return
}
if len(req.Password) > 72 {
failure(w, 400, "Password must be at most 72 bytes")
return
}
repo := db.NewUserRepo(db.FromContext(r.Context()))
// Account enumeration (review SEC-4, CR-03): a distinct 409 — or a
// distinct response BODY — for a registered address is an unauthenticated
// existence oracle. Duplicate registrations (and claims of
// operator-reserved addresses) are logged for the operator and answered
// with the exact same 201 {"ok":true} body as a real creation; the
// internal user id never leaves the server before authentication
// establishes ownership (/api/me). The subsequent login then either
// succeeds (the caller is the owner) or fails on credentials like any
// wrong password.
var existing bool
if _, e := repo.FindByEmail(r.Context(), req.Email); e == nil {
existing = true
} else if !errors.Is(e, db.ErrNotFound) {
failure(w, 500, "Database unavailable")
return
}
if config.IsAdminEmail(req.Email) {
log.Printf("signup: rejected claim of operator-reserved address %s", req.Email)
existing = true
}
if existing {
log.Printf("signup: duplicate registration attempt")
// Same bcrypt cost as real registrations, so timing does not
// re-open the existence oracle.
_, _ = bcryptHashImpl(req.Password)
writeJSON(w, 201, SignupResponse{true})
return
}
hash, e := bcryptHashImpl(req.Password)
if e != nil {
failure(w, 500, "Unable to hash password")
return
}
if req.Name == "" {
req.Name = strings.SplitN(req.Email, "@", 2)[0]
}
if _, e := repo.Create(r.Context(), &db.NewUserInput{ID: db.NewID(), Name: req.Name, Email: req.Email, Password: hash, Role: "USER"}); e != nil {
var pg *pgconn.PgError
if errors.As(e, &pg) && pg.Code == "23505" {
// Racing creation: still indistinguishable.
writeJSON(w, 201, SignupResponse{true})
return
}
failure(w, 500, "Unable to create user")
return
}
writeJSON(w, 201, SignupResponse{true})
}
// checkAuthRequest enforces the JSON Content-Type and same-origin guards on
// the unauthenticated auth endpoints. Applied to login and signup alike
// (review SEC-4): both are reachable directly on the Go API when the Next
// proxy's own Origin checks are bypassed.
func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool {
if strings.ToLower(strings.TrimSpace(strings.SplitN(r.Header.Get("Content-Type"), ";", 2)[0])) != "application/json" {
failure(w, 415, "Content-Type must be application/json")
return false
}
if origin := r.Header.Get("Origin"); origin != "" {
allowed, err := url.Parse(config.Load().NextAuthURL)
scheme := "http"
// Behind a TLS-terminating proxy r.TLS is nil; X-Forwarded-Proto only
// affects the scheme half — the host must still match Origin, so this
// grants a spoofer nothing they control end-to-end.
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
scheme = "https"
}
if origin != scheme+"://"+r.Host && (err != nil || allowed.Host == "" || origin != allowed.Scheme+"://"+allowed.Host) {
failure(w, 403, "Invalid request origin")
return false
}
}
return true
}
func HandleLogin(w http.ResponseWriter, r *http.Request) {
if !checkAuthRequest(w, r) {
return
}
var req SigninRequest
if !decode(w, r, &req) {
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
// Review SEC-1: the failure lockout applies to every peer, loopback
// included — the exemption previously removed all guessing protection
// from same-host deployments.
if retry, locked := loginFailures.locked(EffectiveClientIP(r)+"|"+email, time.Now()); locked {
tooManyRequests(w, retry)
return
}
u, e := db.NewUserRepo(db.FromContext(r.Context())).FindByEmail(r.Context(), email)
if e != nil && !errors.Is(e, db.ErrNotFound) {
failure(w, 500, "Database unavailable")
return
}
// Unknown or password-less accounts still pay a bcrypt comparison so
// response latency does not reveal which emails are registered.
ok := false
if e == nil && u.Password != nil {
ok = bcryptCheckImpl(req.Password, *u.Password)
} else {
bcryptCheckImpl(req.Password, dummyPasswordHash)
}
if !ok {
loginFailures.noteFailure(EffectiveClientIP(r)+"|"+email, time.Now())
failure(w, 401, "Invalid credentials")
return
}
loginFailures.noteSuccess(EffectiveClientIP(r) + "|" + email)
if e = WriteSessionCookie(w, SessionUser{ID: u.ID, Role: u.Role, Ver: u.TokenVersion}, config.Load().Secret()); e != nil {
failure(w, 500, "Session unavailable")
return
}
// tokenVersion rides along so the Next server can embed it in its session
// JWT and in every assertion it signs (review SEC-02); a revocation bump
// then invalidates NextAuth-backed sessions on their next backend call.
writeJSON(w, 200, map[string]any{"ok": true, "user": identity(u), "tokenVersion": u.TokenVersion})
}
func HandleAuth(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/auth/session":
su, e := ReadSession(r, config.Load().Secret())
if e != nil {
writeJSON(w, 200, nil)
return
}
u, e := db.NewUserRepo(db.FromContext(r.Context())).FindByID(r.Context(), su.ID)
if e != nil {
// Session error classification (review follow-up): distinguish
// authentication absence from backend degradation, exactly like
// AuthMiddleware — an unreachable database must answer 503 so
// client retry logic and monitoring see the difference instead of
// a degraded backend masquerading as an ordinary logout.
if errors.Is(e, db.ErrNotFound) {
writeJSON(w, 200, nil)
} else {
failure(w, 503, "Service unavailable")
}
return
}
if su.Ver > 0 && su.Ver != u.TokenVersion {
writeJSON(w, 200, nil)
return
}
writeJSON(w, 200, map[string]any{"user": identity(u)})
case "/api/auth/signout":
if r.Method != "POST" {
failure(w, 405, "Use POST to sign out")
return
}
// Revoke every cookie JWT issued for this user, not just this
// browser's copy: signout means "sign out everywhere".
if su, e := ReadSession(r, config.Load().Secret()); e == nil {
if e := db.NewUserRepo(db.FromContext(r.Context())).BumpTokenVersion(r.Context(), su.ID); e != nil {
writeJSON(w, 500, map[string]string{"error": "Unable to sign out"})
return
}
}
ClearSessionCookie(w)
// Review SEC-6: sign-out ends impersonation too — otherwise the next
// admin login on that browser would silently resume view-as.
http.SetCookie(w, &http.Cookie{Name: ViewAsCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
writeJSON(w, 200, map[string]bool{"ok": true})
default:
failure(w, 404, "Unknown authentication endpoint")
}
}
func HandleMe(w http.ResponseWriter, r *http.Request) {
s, _ := SessionFromContext(r.Context())
var viewing any
if s.ID != s.RealID {
viewing = map[string]any{"id": s.ID, "name": s.Name, "email": s.Email}
}
writeJSON(w, 200, map[string]any{"user": s.RealUser, "effectiveUser": map[string]any{"id": s.ID, "name": s.Name, "email": s.Email, "role": s.Role}, "isAdmin": s.RealRole == "ADMIN", "viewingAs": viewing})
}
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"context"
"errors"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/models"
"log"
"net/http"
"sync"
)
const ViewAsCookie = "computer_view_as"
type SessionUser struct {
ID, Role, RealID, RealRole string
Name, Email *string
RealUser map[string]any
// Ver is the tokenVersion embedded in the credential (0 = no claim:
// pre-revocation legacy cookies only — assertions must carry it), checked
// against the user row so signout revokes cookie sessions AND NextAuth
// assertions alike (review SEC-02).
Ver int
}
func identity(u *models.User) map[string]any {
return map[string]any{"id": u.ID, "name": u.Name, "email": u.Email, "image": u.Image, "role": u.Role}
}
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, e := ReadSession(r, config.Load().Secret())
if e != nil {
failure(w, 401, "Unauthorized")
return
}
repo := db.NewUserRepo(db.FromContext(r.Context()))
u, e := repo.FindByID(r.Context(), s.ID)
if e != nil {
// Review CORR-03: a missing user is a credential problem (401),
// but backend degradation must not masquerade as one — surface it
// as 503 so client retry logic and monitoring see the difference.
if errors.Is(e, db.ErrNotFound) {
failure(w, 401, "Unauthorized")
} else {
log.Printf("auth: user lookup failed for session %s: %v", s.ID, e)
failure(w, 503, "Service unavailable")
}
return
}
// Revoked session: the credential's tokenVersion predates a signout
// bump. Applies to cookie sessions and NextAuth assertions alike.
if s.Ver > 0 && s.Ver != u.TokenVersion {
failure(w, 401, "Unauthorized")
return
}
// Promotion via ADMIN_EMAILS is persistent by design: removing an
// address from the env var later does NOT demote the account (use the
// Admin screen). Silent demotion on env drift would be worse than an
// extra explicit revocation step.
//
// Code review SEC-04 flagged that this re-promotes an account demoted
// via the Admin screen while its address remains in ADMIN_EMAILS. The
// env-driven promotion is a deliberate architectural decision here —
// demoting an operator account means editing ADMIN_EMAILS too; do not
// "fix" this silently.
if u.Role != "ADMIN" && u.Email != nil && u.EmailVerified != nil && config.IsAdminEmail(*u.Email) {
if e = repo.SetRole(r.Context(), u.ID, "ADMIN"); e != nil {
failure(w, 500, "Unable to resolve role")
return
}
u.Role = "ADMIN"
}
s.RealID, s.RealRole, s.RealUser = u.ID, u.Role, identity(u)
s.ID, s.Role, s.Name, s.Email = u.ID, u.Role, u.Name, u.Email
if u.Role == "ADMIN" {
if c, e := r.Cookie(ViewAsCookie); e == nil && c.Value != "" && c.Value != u.ID {
if target, e := repo.FindByID(r.Context(), c.Value); e == nil {
s.ID, s.Role, s.Name, s.Email = target.ID, target.Role, target.Name, target.Email
// Review SEC-6: impersonation is audited. Log at most once
// per admin/target/method combination per process so a
// polling UI does not flood the log.
auditViewAs(u.ID, target.ID, r.Method)
}
}
}
// Review SEC-6: view-as is read-only. Mutations while impersonating
// are attributed to the target user, so writes must come from the
// admin's own identity — exit view-as first. The view-as endpoint
// itself is exempt: it controls the impersonation session and must
// be writable to both start and stop the session.
if s.ID != s.RealID && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/api/admin/view-as" {
failure(w, 403, "Read-only while viewing as another user")
return
}
next.ServeHTTP(w, r.WithContext(WithSession(r.Context(), s)))
})
}
type sessionKey struct{}
// auditViewAs logs each distinct (admin, target, method) impersonation
// observation once per process (review SEC-6). Ids only — never names,
// emails, or request payloads.
var (
auditViewAsMu sync.Mutex
auditViewAsLog = map[string]bool{}
)
func auditViewAs(adminID, targetID, method string) {
key := adminID + "|" + targetID + "|" + method
auditViewAsMu.Lock()
defer auditViewAsMu.Unlock()
if auditViewAsLog[key] {
return
}
auditViewAsLog[key] = true
log.Printf("audit: admin %s viewing as %s (%s)", adminID, targetID, method)
}
func WithSession(ctx context.Context, s *SessionUser) context.Context {
return context.WithValue(ctx, sessionKey{}, s)
}
func SessionFromContext(ctx context.Context) (*SessionUser, bool) {
s, ok := ctx.Value(sessionKey{}).(*SessionUser)
return s, ok
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, ok := SessionFromContext(r.Context())
if !ok || s.RealRole != "ADMIN" {
failure(w, 403, "Forbidden")
return
}
next.ServeHTTP(w, r)
})
}
+302
View File
@@ -0,0 +1,302 @@
// Abuse protection for the unauthenticated auth endpoints: a fixed-window
// counter per IP plus consecutive-failure lockout per (IP, email) on login.
//
// Review SEC-1: the login endpoint and its failure lockout apply to EVERY
// peer, including loopback. In same-host non-Docker deployments the Next
// server reaches Go over localhost, and exempting that path removed all
// throttling from real browser logins (unlimited online password guessing).
// Signup keeps the loopback exemption: it exists for same-host consumers of
// the 127.0.0.1 binding, and a forwarded identity never earns it (SEC-2).
//
// Review AUTH-RATE: keying strictly on the direct peer made every browser
// behind the Next proxy share one bucket (in Compose, Go's peer is always
// the web container). The trusted Next server therefore forwards the browser
// IP it observed, signed with HMAC-SHA256(secret, ip|unix_ts) under the
// shared auth secret (web/lib/api.ts, web/auth.ts), with a 60s replay
// window. Go only accepts the forwarded identity when the signature and
// timestamp verify, so hostile clients cannot forge it; unsigned or invalid
// values fall back to the direct peer. Next forwards only X-Real-IP (never
// X-Forwarded-For) and only when TRUSTED_PROXY=1 pins the proxy that
// overwrites that header — the shipped nginx template does.
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"github.com/andrew/go-computer/internal/config"
"net"
"net/http"
"sort"
"strconv"
"sync"
"time"
)
const (
loginIPLimit = 20 // logins per IP per window
loginIPWindow = time.Minute //
signupIPLimit = 10 // signups per IP per window
signupIPWindow = time.Hour //
failureThreshold = 5 // consecutive failures before lockout
failureLockout = 15 * time.Minute
failureWindow = time.Hour // failures older than this are forgotten
// limiterMaxEntries is the soft cap per store. Review SEC-03: the failure
// tracker previously never evicted sub-threshold entries (zero until), so
// cycling unique emails grew the map without bound. Every mutator now
// sweeps once the cap is exceeded.
limiterMaxEntries = 4096
)
type counter struct {
count int
reset time.Time
until time.Time // lockout expiry (failure tracker only)
}
type limiterStore struct {
mu sync.Mutex
m map[string]*counter
}
func newLimiterStore() *limiterStore { return &limiterStore{m: map[string]*counter{}} }
// sweep bounds memory (review SEC-03). Callers hold s.mu. Pass 1 drops
// entries whose window — and lockout, for the failure tracker — have expired.
// If a burst of still-live keys (e.g. unique-email login failures inside the
// failure window) keeps the store oversized, pass 2 evicts the oldest-tracked
// non-locked entries until the cap holds again; active lockouts are left
// alone so an eviction can never shorten a lockout.
func (s *limiterStore) sweep(now time.Time) {
if len(s.m) <= limiterMaxEntries {
return
}
for k, c := range s.m {
if now.After(c.reset) && (c.until.IsZero() || now.After(c.until)) {
delete(s.m, k)
}
}
if len(s.m) <= limiterMaxEntries {
return
}
keys := make([]string, 0, len(s.m))
for k, c := range s.m {
if c.until.IsZero() || now.After(c.until) {
keys = append(keys, k)
}
}
sort.Slice(keys, func(i, j int) bool { return s.m[keys[i]].reset.Before(s.m[keys[j]].reset) })
for _, k := range keys[:min(len(keys), len(s.m)-limiterMaxEntries)] {
delete(s.m, k)
}
}
// allow reports whether key is within limit for the window starting at first
// use. Expiry returns the remaining time until the window resets so callers
// can send an accurate Retry-After header (review ERR-3).
func (s *limiterStore) allow(key string, limit int, window time.Duration, now time.Time) (bool, time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
s.sweep(now)
c := s.m[key]
if c == nil || now.After(c.reset) {
s.m[key] = &counter{count: 1, reset: now.Add(window)}
return true, 0
}
c.count++
retry := c.reset.Sub(now)
if retry < 0 {
retry = 0
}
return c.count <= limit, retry.Truncate(time.Second) + time.Second
}
func (s *limiterStore) locked(key string, now time.Time) (time.Duration, bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.sweep(now)
c := s.m[key]
if c == nil || c.until.IsZero() {
return 0, false
}
if now.After(c.until) {
delete(s.m, key)
return 0, false
}
// Consistent clock (review SEC-03): derive the retry delay from the
// supplied now rather than mixing in time.Until.
return c.until.Sub(now).Truncate(time.Second) + time.Second, true
}
func (s *limiterStore) noteFailure(key string, now time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.sweep(now)
c := s.m[key]
if c == nil || now.After(c.reset) {
c = &counter{reset: now.Add(failureWindow)}
s.m[key] = c
}
c.count++
if c.count >= failureThreshold {
c.until = now.Add(failureLockout)
}
}
func (s *limiterStore) noteSuccess(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, key)
}
var (
loginIPs = newLimiterStore()
signupIPs = newLimiterStore()
loginFailures = newLimiterStore()
)
// ResetRateLimits clears all limiter state. Tests only: the stores are
// process-global, and integration harnesses reset them per test server so
// sequential tests do not inherit each other's buckets (the login endpoint
// has no loopback exemption since review SEC-1).
func ResetRateLimits() {
for _, s := range []*limiterStore{loginIPs, signupIPs, loginFailures} {
s.mu.Lock()
s.m = map[string]*counter{}
s.mu.Unlock()
}
}
// resetRateLimits clears all limiter state. Tests only.
func resetRateLimits() { ResetRateLimits() }
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// Headers carrying the proxy-attested browser IP (review AUTH-RATE). The
// signature is HMAC-SHA256(secret, ip|unix_ts) in hex, computed by the
// trusted Next server; anything else falls back to the direct peer address.
// The timestamp bounds replay of a captured header pair to ~60s (review
// SEC-2). Next only forwards an IP when TRUSTED_PROXY=1 pins the boundary
// that overwrites X-Real-IP (the shipped nginx template does).
const (
headerClientIP = "X-Computer-Client-Ip"
headerClientTs = "X-Computer-Client-Ts"
headerClientSig = "X-Computer-Client-Sig"
// forwardSkew bounds clock skew and replay of the signed identity.
forwardSkew = 60 * time.Second
)
func SignClientIP(secret, ip string, ts int64) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
func signedForwardIP(r *http.Request) (string, bool) {
ip := r.Header.Get(headerClientIP)
sig := r.Header.Get(headerClientSig)
tsRaw := r.Header.Get(headerClientTs)
if ip == "" || sig == "" || tsRaw == "" || net.ParseIP(ip) == nil {
return "", false
}
ts, err := strconv.ParseInt(tsRaw, 10, 64)
if err != nil {
return "", false
}
now := time.Now().Unix()
if ts > now+int64(forwardSkew/time.Second) || ts < now-int64(forwardSkew/time.Second) {
return "", false
}
secret := config.Load().Secret()
if secret == "" {
return "", false
}
if !hmac.Equal([]byte(SignClientIP(secret, ip, ts)), []byte(sig)) {
return "", false
}
return ip, true
}
// EffectiveClientIP is the identity rate limiting buckets on: the signed
// forwarded browser IP when the trusted proxy attested one, else the direct
// peer. Never trust arbitrary forwarded headers here.
func EffectiveClientIP(r *http.Request) string {
if ip, ok := signedForwardIP(r); ok {
return ip
}
return clientIP(r)
}
// isLoopback reports whether the DIRECT peer is localhost. A forwarded
// identity — even a signed loopback one — never earns the exemption (review
// SEC-2): the exemption exists for same-host consumers of the 127.0.0.1
// binding, not for anything arriving through a proxy. Plain X-Forwarded-For
// is client-controlled and never trusted.
func isLoopback(r *http.Request) bool {
if _, forwarded := signedForwardIP(r); forwarded {
return false
}
ip := net.ParseIP(clientIP(r))
return ip != nil && ip.IsLoopback()
}
func tooManyRequests(w http.ResponseWriter, retry time.Duration) {
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())))
failure(w, http.StatusTooManyRequests, "Too many attempts, slow down")
}
// RateLimitLogin caps login attempts per effective client IP for every peer
// (review SEC-1: loopback is NOT exempt on the login endpoint).
func RateLimitLogin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Review ERR-3: Retry-After carries the remaining window time, not
// the full window.
if ok, retry := loginIPs.allow(EffectiveClientIP(r), loginIPLimit, loginIPWindow, time.Now()); !ok {
tooManyRequests(w, retry)
return
}
next(w, r)
}
}
// RateLimitSignup caps account creation per effective client IP for
// non-loopback peers.
func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isLoopback(r) {
if ok, retry := signupIPs.allow(EffectiveClientIP(r), signupIPLimit, signupIPWindow, time.Now()); !ok {
tooManyRequests(w, retry)
return
}
}
next(w, r)
}
}
// NewLimiterStore creates a new in-memory rate limiter store. Exported so
// callers (e.g. public endpoints) can have independent buckets.
func NewLimiterStore() *limiterStore { return newLimiterStore() }
// Limiter wraps a handler with a simple per-IP fixed-window rate limit.
// Uses the direct peer IP (no forwarded headers) and does not exempt
// loopback — public endpoints apply to every peer.
func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if ok, retry := s.allow(ip, limit, window, time.Now()); !ok {
tooManyRequests(w, retry)
return
}
next(w, r)
}
}
}
+245
View File
@@ -0,0 +1,245 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
func TestLimiterStoreWindow(t *testing.T) {
s := newLimiterStore()
now := time.Now()
for i := 0; i < 5; i++ {
if ok, _ := s.allow("ip", 5, time.Minute, now.Add(time.Duration(i)*time.Second)); !ok {
t.Fatalf("request %d within limit rejected", i+1)
}
}
// Review ERR-3: the rejection carries the remaining window time.
ok, retry := s.allow("ip", 5, time.Minute, now.Add(10*time.Second))
if ok {
t.Fatal("request over limit accepted")
}
if retry <= 0 || retry > time.Minute {
t.Fatalf("retry-after %v outside the remaining window", retry)
}
if ok, _ := s.allow("ip", 5, time.Minute, now.Add(61*time.Second)); !ok {
t.Fatal("request after window reset rejected")
}
if ok, _ := s.allow("other", 5, time.Minute, now); !ok {
t.Fatal("separate key shares the limit")
}
}
func TestFailureLockout(t *testing.T) {
s := newLimiterStore()
now := time.Now()
for i := 0; i < failureThreshold-1; i++ {
s.noteFailure("ip|user", now.Add(time.Duration(i)*time.Second))
}
if _, locked := s.locked("ip|user", now.Add(time.Minute)); locked {
t.Fatal("locked below threshold")
}
s.noteFailure("ip|user", now.Add(time.Minute))
retry, locked := s.locked("ip|user", now.Add(2*time.Minute))
if !locked || retry <= 0 {
t.Fatalf("not locked after %d failures: %v %v", failureThreshold, retry, locked)
}
s.noteSuccess("ip|user")
if _, locked := s.locked("ip|user", now.Add(2*time.Minute)); locked {
t.Fatal("successful login did not clear the lockout")
}
if _, locked := s.locked("ip|user", now.Add(failureLockout+time.Hour)); locked {
t.Fatal("lockout did not expire")
}
}
// Review SEC-03: sub-threshold failure entries (zero until) must not
// accumulate without bound when an attacker cycles unique emails.
func TestFailureTrackerEviction(t *testing.T) {
s := newLimiterStore()
now := time.Now()
// Entries tracked two hours ago are fully expired (failureWindow passed).
old := now.Add(-2 * time.Hour)
for i := 0; i < limiterMaxEntries+500; i++ {
s.noteFailure(fmt.Sprintf("ip|user%d", i), old)
}
if len(s.m) > limiterMaxEntries+1 {
t.Fatalf("live-key burst exceeded the soft cap: %d entries", len(s.m))
}
// A later call sweeps the fully expired burst.
s.noteFailure("ip|fresh", now)
if len(s.m) != 1 {
t.Fatalf("expired failure entries not evicted: %d remain", len(s.m))
}
// Active lockouts survive eviction from an oversized store.
s2 := newLimiterStore()
for i := 0; i < failureThreshold; i++ {
s2.noteFailure("ip|locked", now.Add(time.Duration(i)*time.Second))
}
for i := 0; i < limiterMaxEntries+500; i++ {
s2.noteFailure(fmt.Sprintf("ip|user%d", i), now)
}
if _, locked := s2.locked("ip|locked", now.Add(time.Minute)); !locked {
t.Fatal("active lockout evicted by the memory sweep")
}
}
func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
func TestRateLimitMiddleware(t *testing.T) {
resetRateLimits()
t.Cleanup(resetRateLimits)
// Review SEC-1: loopback is NOT exempt on the login endpoint — same-host
// deployments must keep throttling for real browser logins.
loop := httptest.NewRequest("POST", "/api/auth/login", nil)
loop.RemoteAddr = "127.0.0.1:54321"
var loopLast *httptest.ResponseRecorder
for i := 0; i < loginIPLimit+1; i++ {
loopLast = httptest.NewRecorder()
RateLimitLogin(dummyHandler)(loopLast, loop)
}
if loopLast.Code != http.StatusTooManyRequests {
t.Fatalf("loopback login exempt: got %d", loopLast.Code)
}
// Signup keeps the loopback exemption for host-local consumers.
resetRateLimits()
var loopSignup *httptest.ResponseRecorder
for i := 0; i < signupIPLimit*2; i++ {
loopSignup = httptest.NewRecorder()
RateLimitSignup(dummyHandler)(loopSignup, loop)
}
if loopSignup.Code != 204 {
t.Fatalf("loopback signup limited with %d", loopSignup.Code)
}
resetRateLimits()
// Non-loopback peers get 429 after the window limit, with Retry-After.
remote := httptest.NewRequest("POST", "/api/auth/login", nil)
remote.RemoteAddr = "203.0.113.7:1234"
var last *httptest.ResponseRecorder
for i := 0; i < loginIPLimit+1; i++ {
last = httptest.NewRecorder()
RateLimitLogin(dummyHandler)(last, remote)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 after limit, got %d", last.Code)
}
if last.Header().Get("Retry-After") == "" {
t.Fatal("429 without Retry-After header")
}
// Signup limiter counts independently.
resetRateLimits()
var signupLast *httptest.ResponseRecorder
for i := 0; i < signupIPLimit+1; i++ {
signupLast = httptest.NewRecorder()
RateLimitSignup(dummyHandler)(signupLast, remote)
}
if signupLast.Code != http.StatusTooManyRequests {
t.Fatalf("signup: expected 429 after limit, got %d", signupLast.Code)
}
w := httptest.NewRecorder()
RateLimitLogin(dummyHandler)(w, remote)
if w.Code != 204 {
t.Fatalf("signup exhaustion must not limit login: %d", w.Code)
}
}
// Review AUTH-RATE: Go accepts a forwarded client IP only when the trusted
// Next proxy signed it with the shared secret; forged headers fall back to
// the direct peer, so one browser cannot evade or poison another's bucket.
func TestSignedForwardedClientIP(t *testing.T) {
t.Setenv("NEXTAUTH_SECRET", "")
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
ts := time.Now().Unix()
sign := func(ip string, when int64) (string, string) {
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
mac.Write([]byte(ip + "|" + strconv.FormatInt(when, 10)))
return strconv.FormatInt(when, 10), hex.EncodeToString(mac.Sum(nil))
}
r := httptest.NewRequest("POST", "/api/auth/login", nil)
r.RemoteAddr = "203.0.113.9:5555"
if got := EffectiveClientIP(r); got != "203.0.113.9" {
t.Fatalf("unsigned request: got %s", got)
}
hts, sig := sign("198.51.100.7", ts)
r.Header.Set(headerClientIP, "198.51.100.7")
r.Header.Set(headerClientTs, hts)
r.Header.Set(headerClientSig, sig)
if got := EffectiveClientIP(r); got != "198.51.100.7" {
t.Fatalf("signed forwarded IP ignored: got %s", got)
}
_, badSig := sign("198.51.100.8", ts)
r.Header.Set(headerClientSig, badSig)
if got := EffectiveClientIP(r); got != "203.0.113.9" {
t.Fatalf("forged signature accepted: got %s", got)
}
goodTs, goodSig := sign("198.51.100.7", ts)
r.Header.Set(headerClientSig, goodSig)
r.Header.Set(headerClientTs, goodTs)
r.Header.Set(headerClientIP, "198.51.100.7; DROP TABLE users")
if got := EffectiveClientIP(r); got != "203.0.113.9" {
t.Fatalf("non-IP forwarded value accepted: got %s", got)
}
// Review SEC-2: a forwarded identity never earns the loopback exemption,
// even when it attests 127.0.0.1; and stale timestamps fall back to the
// direct peer.
loopTs, loopSig := sign("127.0.0.1", ts)
r.Header.Set(headerClientIP, "127.0.0.1")
r.Header.Set(headerClientTs, loopTs)
r.Header.Set(headerClientSig, loopSig)
if isLoopback(r) {
t.Fatal("signed loopback forwarded IP must not earn the exemption")
}
staleTs, staleSig := sign("198.51.100.7", ts-3600)
r.Header.Set(headerClientIP, "198.51.100.7")
r.Header.Set(headerClientTs, staleTs)
r.Header.Set(headerClientSig, staleSig)
if got := EffectiveClientIP(r); got != "203.0.113.9" {
t.Fatalf("stale signature accepted: got %s", got)
}
}
func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
t.Setenv("NEXTAUTH_SECRET", "")
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
resetRateLimits()
ts := time.Now().Unix()
sign := func(ip string) (string, string) {
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
}
handler := RateLimitLogin(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
do := func(ip string) int {
r := httptest.NewRequest("POST", "/api/auth/login", nil)
r.RemoteAddr = "10.0.0.5:1234" // shared proxy peer
hts, sig := sign(ip)
r.Header.Set(headerClientIP, ip)
r.Header.Set(headerClientTs, hts)
r.Header.Set(headerClientSig, sig)
w := httptest.NewRecorder()
handler(w, r)
return w.Code
}
for i := 0; i < loginIPLimit; i++ {
if code := do("198.51.100.1"); code != 200 {
t.Fatalf("browser 1 request %d: %d", i+1, code)
}
}
if code := do("198.51.100.1"); code != http.StatusTooManyRequests {
t.Fatalf("browser 1 over limit: %d", code)
}
// An unrelated browser behind the same proxy keeps its own budget.
if code := do("198.51.100.2"); code != 200 {
t.Fatalf("browser 2 blocked by browser 1: %d", code)
}
resetRateLimits()
}
+180
View File
@@ -0,0 +1,180 @@
package auth
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/andrew/go-computer/internal/config"
"github.com/golang-jwt/jwt/v5"
)
// SessionStrategy mirrors the original NextAuth setup: a stateless signed JWT
// cookie. The jwt callback stores token.id = user.id and token.provider; the
// session callback copies token.id to session.user.id (porting guide 3.1).
const (
sessionCookieName = "computer_session"
// time.Duration is nanoseconds, so multiply by time.Second explicitly.
sessionMaxAge = 30 * 24 * 60 * 60 * time.Second // 30 days
// Token types bind each token to its channel: the trusted Next server
// signs short-lived "assertion" tokens for the Bearer header; Go signs
// 30-day "session" tokens for its own cookie. Neither is accepted on the
// other channel, so a long-lived cookie JWT can never be replayed as a
// Bearer credential (and vice versa).
TokenTypeSession = "session"
TokenTypeAssertion = "assertion"
// maxAssertionLifetime is the server-side cap for Bearer assertions; the
// Next server signs 60-second tokens.
maxAssertionLifetime = 120 * time.Second
)
// SessionClaims is the JWT payload.
type SessionClaims struct {
UserID string `json:"uid"`
Role string `json:"role"`
Type string `json:"typ,omitempty"`
// Ver mirrors User.tokenVersion (≥1). Required on assertions (review
// SEC-02: a tokenVersion bump must revoke NextAuth-backed sessions too,
// not just Go cookie sessions); pre-revocation legacy cookies omit it and
// skip the revocation check.
Ver int `json:"ver,omitempty"`
jwt.RegisteredClaims
}
// SignSession issues a signed JWT cookie value for the user.
func SignSession(u SessionUser, secret string) (string, error) {
if secret == "" {
return "", fmt.Errorf("auth: empty session secret")
}
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{
UserID: u.ID,
Role: u.Role,
Type: TokenTypeSession,
Ver: u.Ver,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(sessionMaxAge)),
Issuer: "computer",
},
})
return tok.SignedString([]byte(secret))
}
// VerifySession decodes a JWT cookie value into the session user. It checks
// signature, algorithm, issuer and expiry — but NOT the channel binding; use
// ReadSession for request handling.
func VerifySession(token, secret string) (*SessionUser, error) {
claims, err := verifyClaims(token, secret)
if err != nil {
return nil, err
}
return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil
}
func verifyClaims(token, secret string) (*SessionClaims, error) {
if secret == "" {
return nil, fmt.Errorf("auth: empty session secret")
}
tok, err := jwt.ParseWithClaims(token, &SessionClaims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
}, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("computer"), jwt.WithExpirationRequired())
if err != nil {
return nil, err
}
claims, ok := tok.Claims.(*SessionClaims)
if !ok || !tok.Valid || claims.UserID == "" {
return nil, errors.New("auth: invalid token")
}
return claims, nil
}
// ReadSession pulls the session credential from the request and verifies it,
// enforcing the channel binding: Bearer requires a short-lived assertion; the
// cookie requires a session token (tokens issued before typ existed, i.e. no
// typ claim, remain valid on the cookie channel only).
func ReadSession(r *http.Request, secret string) (*SessionUser, error) {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
claims, err := verifyClaims(strings.TrimPrefix(h, "Bearer "), secret)
if err != nil {
return nil, err
}
if claims.Type != TokenTypeAssertion {
return nil, errors.New("auth: bearer token is not an assertion")
}
if claims.IssuedAt == nil || claims.ExpiresAt == nil || claims.ExpiresAt.Sub(claims.IssuedAt.Time) > maxAssertionLifetime {
return nil, errors.New("auth: assertion lifetime too long")
}
// Review SEC-02: assertions must carry the session's tokenVersion so a
// revocation bump ("sign out everywhere") invalidates NextAuth-backed
// browser sessions on their next backend call, instead of leaving them
// minting valid assertions until the NextAuth JWT expires.
if claims.Ver < 1 {
return nil, errors.New("auth: assertion missing token version")
}
return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil
}
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, err
}
claims, err := verifyClaims(c.Value, secret)
if err != nil {
return nil, err
}
if claims.Type != "" && claims.Type != TokenTypeSession {
return nil, errors.New("auth: cookie token has wrong type")
}
// Review SEC-7: legacy session cookies without a `ver` claim (Ver == 0)
// were minted before revocation was implemented and would bypass the
// tokenVersion check entirely ("sign out everywhere" would not affect
// them until expiry). Treat a missing/zero ver as revoked so users must
// re-authenticate.
if claims.Ver < 1 {
return nil, errors.New("auth: session lacks required version claim")
}
return &SessionUser{ID: claims.UserID, Role: claims.Role, Ver: claims.Ver}, nil
}
// WriteSessionCookie sets the signed session cookie on the response.
func WriteSessionCookie(w http.ResponseWriter, u SessionUser, secret string) error {
tok, err := SignSession(u, secret)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: tok,
Path: "/",
MaxAge: int(sessionMaxAge.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure(),
})
return nil
}
// ClearSessionCookie expires the session cookie.
func ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure(),
})
}
// isSecure derives the cookie Secure flag from configuration: explicit
// COOKIE_SECURE wins, otherwise an https NEXTAUTH_URL implies secure.
func isSecure() bool {
return config.Load().CookieSecure()
}
// ErrUnauthorized is returned when there is no valid session.
var ErrUnauthorized = errors.New("auth: unauthorized")
+113
View File
@@ -0,0 +1,113 @@
package auth
import (
"github.com/golang-jwt/jwt/v5"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSessionLifetimeAndVerification(t *testing.T) {
token, e := SignSession(SessionUser{ID: "alice"}, "test-secret")
if e != nil {
t.Fatal(e)
}
s, e := VerifySession(token, "test-secret")
if e != nil || s.ID != "alice" {
t.Fatalf("session: %v %v", s, e)
}
var claims SessionClaims
_, _, e = jwt.NewParser().ParseUnverified(token, &claims)
if e != nil {
t.Fatal(e)
}
if d := claims.ExpiresAt.Sub(claims.IssuedAt.Time); d != 30*24*time.Hour {
t.Fatalf("token lifetime: %v", d)
}
if _, e = VerifySession(token, "wrong-secret"); e == nil {
t.Fatal("accepted wrong secret")
}
expired := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{UserID: "alice", RegisteredClaims: jwt.RegisteredClaims{Issuer: "computer", ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour))}})
v, _ := expired.SignedString([]byte("test-secret"))
if _, e = VerifySession(v, "test-secret"); e == nil {
t.Fatal("accepted expired token")
}
token256 := jwt.NewWithClaims(jwt.SigningMethodHS384, SessionClaims{UserID: "alice", RegisteredClaims: jwt.RegisteredClaims{Issuer: "computer", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))}})
v, _ = token256.SignedString([]byte("test-secret"))
if _, e = VerifySession(v, "test-secret"); e == nil {
t.Fatal("accepted unexpected algorithm")
}
}
func signTestToken(t *testing.T, typ string, ttl time.Duration, ver int) string {
t.Helper()
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{
UserID: "alice", Type: typ, Ver: ver,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "computer",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
})
v, e := tok.SignedString([]byte("test-secret"))
if e != nil {
t.Fatal(e)
}
return v
}
// The Bearer channel accepts only short-lived assertions carrying a token
// version (review SEC-02); the cookie channel accepts only session tokens (or
// legacy tokens issued before typ existed). A 30-day cookie JWT must never
// work as a Bearer credential.
func TestTokenChannelBinding(t *testing.T) {
assertion := signTestToken(t, TokenTypeAssertion, 60*time.Second, 3)
versionlessAssertion := signTestToken(t, TokenTypeAssertion, 60*time.Second, 0)
longAssertion := signTestToken(t, TokenTypeAssertion, time.Hour, 3)
session, e := SignSession(SessionUser{ID: "alice"}, "test-secret")
if e != nil {
t.Fatal(e)
}
legacy := signTestToken(t, "", time.Hour, 0)
// Review SEC-7: cookies now require Ver >= 1. Create a versioned session
// cookie for the channel-binding test.
versionedSession := signTestToken(t, TokenTypeSession, time.Hour, 3)
bearer := func(token string) (*SessionUser, error) {
r := httptest.NewRequest("GET", "/api/me", nil)
r.Header.Set("Authorization", "Bearer "+token)
return ReadSession(r, "test-secret")
}
cookie := func(token string) (*SessionUser, error) {
r := httptest.NewRequest("GET", "/api/me", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
return ReadSession(r, "test-secret")
}
if s, e := bearer(assertion); e != nil || s.ID != "alice" || s.Ver != 3 {
t.Fatalf("valid assertion rejected or lost its version: %v %v", s, e)
}
if _, e := bearer(versionlessAssertion); e == nil {
t.Fatal("assertion without token version accepted (revocation bypass)")
}
if _, e := bearer(session); e == nil {
t.Fatal("30-day session token accepted as Bearer assertion")
}
if _, e := bearer(longAssertion); e == nil {
t.Fatal("over-long assertion accepted (exp-iat cap not enforced)")
}
if _, e := bearer(legacy); e == nil {
t.Fatal("typ-less token accepted as Bearer assertion")
}
if s, e := cookie(versionedSession); e != nil || s.ID != "alice" {
t.Fatalf("valid session cookie rejected: %v %v", s, e)
}
if s, e := cookie(legacy); e == nil {
t.Fatalf("legacy typ-less cookie with Ver=0 must be rejected: %v %v", s, e)
}
if _, e := cookie(assertion); e == nil {
t.Fatal("assertion accepted as cookie session")
}
}
+38
View File
@@ -0,0 +1,38 @@
package auth
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
// Signup input validation must reject before any database access, so these
// run without a DB context.
func TestSignupValidation(t *testing.T) {
cases := []struct {
name string
body map[string]any
}{
{"empty", map[string]any{}},
{"missing password", map[string]any{"email": "ok@computer.test"}},
{"invalid email", map[string]any{"email": "not-an-email", "password": "long-enough-password"}},
{"email with display name", map[string]any{"email": "Name <ok@computer.test>", "password": "long-enough-password"}},
{"short password", map[string]any{"email": "ok@computer.test", "password": "short"}},
{"7 boundary password", map[string]any{"email": "ok@computer.test", "password": "1234567"}},
{"overlong password", map[string]any{"email": "ok@computer.test", "password": strings.Repeat("a", 73)}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, _ := json.Marshal(tc.body)
req := httptest.NewRequest("POST", "/api/signup", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
HandleSignup(w, req)
if w.Code != 400 {
t.Fatalf("wanted 400, got %d: %s", w.Code, w.Body.String())
}
})
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package authguard owns administrative handlers. Session resolution lives in
// auth.AuthMiddleware: real identity governs administration; effective identity
// governs all per-user data queries.
package authguard
+88
View File
@@ -0,0 +1,88 @@
package authguard
import (
"encoding/json"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/handlers"
"log"
"net/http"
"time"
)
func HandleAdminListUsers(w http.ResponseWriter, r *http.Request) {
// COALESCE(email,'') (review COR-8): NOT LIKE evaluates to NULL for the
// NULL emails OIDC-sourced accounts can carry, which hid them from the
// admin list entirely.
v, e := db.Many(r.Context(), db.FromContext(r.Context()), `SELECT jsonb_build_object('id',u.id,'name',u.name,'email',u.email,'image',u.image,'role',u.role,'createdAt',u."createdAt",'_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "Bookmark" b WHERE b."ownerId"=u.id),'pages',(SELECT count(*) FROM "Page" p WHERE p."ownerId"=u.id),'tags',(SELECT count(*) FROM "Tag" t WHERE t."ownerId"=u.id))) FROM "User" u WHERE COALESCE(email,'') NOT LIKE '%@example.com' ORDER BY "createdAt"`)
if e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Database unavailable"})
return
}
handlers.WriteJSON(w, 200, v)
}
func HandleAdminPatchUser(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID string `json:"userId"`
Role string `json:"role"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&b) != nil || (b.Role != "USER" && b.Role != "ADMIN") {
handlers.WriteJSON(w, 400, handlers.Error{Error: "Invalid role"})
return
}
s, _ := auth.SessionFromContext(r.Context())
if b.UserID == s.RealID && b.Role != "ADMIN" {
handlers.WriteJSON(w, 400, handlers.Error{Error: "You cannot demote yourself"})
return
}
d := db.FromContext(r.Context())
repo := db.NewUserRepo(d)
target, e := repo.FindByID(r.Context(), b.UserID)
if e != nil {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
// Review SEC-11: an installation must keep at least one administrator.
// Self-demotion is already refused; this closes the "demote the last
// other admin" path.
if b.Role == "USER" && target.Role == "ADMIN" {
var admins int
if e := d.QueryRowContext(r.Context(), `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"})
return
}
if admins <= 1 {
handlers.WriteJSON(w, 400, handlers.Error{Error: "At least one administrator must remain"})
return
}
}
if e := repo.SetRole(r.Context(), b.UserID, b.Role); e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"})
return
}
// Review SEC-11: role changes are audited. Ids and roles only.
log.Printf("audit: admin %s changed role of %s: %s -> %s", s.RealID, b.UserID, target.Role, b.Role)
handlers.WriteJSON(w, 200, handlers.OK{OK: true})
}
func HandleAdminViewAs(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID *string `json:"userId"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&b) != nil {
handlers.WriteJSON(w, 400, handlers.Error{Error: "Invalid JSON"})
return
}
s, _ := auth.SessionFromContext(r.Context())
c := http.Cookie{Name: auth.ViewAsCookie, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: config.Load().CookieSecure(), MaxAge: -1}
if b.UserID != nil && *b.UserID != "" && *b.UserID != s.RealID {
if _, e := db.NewUserRepo(db.FromContext(r.Context())).FindByID(r.Context(), *b.UserID); e != nil {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
c.Value = *b.UserID
c.MaxAge = int((8 * time.Hour).Seconds())
}
http.SetCookie(w, &c)
handlers.WriteJSON(w, 200, handlers.OK{OK: true})
}
+113
View File
@@ -0,0 +1,113 @@
package config
import (
"net/url"
"os"
"strings"
)
// DBURL returns the database connection string, preferring DATABASE_URL and
// falling back to DB_URL (used by the Makefile).
func DBURL() string {
if v := os.Getenv("DATABASE_URL"); v != "" {
return v
}
return os.Getenv("DB_URL")
}
// Config holds the runtime configuration, loaded from environment variables.
// See porting guide section 7.
type Config struct {
DatabaseURL string
NextAuthSecret string // == AUTH_SECRET; signs sessions and derives IMAP key
AuthSecret string
NextAuthURL string
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
AdminEmails []string
}
// Load reads configuration from the environment. Placeholders keep OIDC off.
func Load() *Config {
return &Config{
DatabaseURL: DBURL(),
NextAuthSecret: os.Getenv("NEXTAUTH_SECRET"),
AuthSecret: os.Getenv("AUTH_SECRET"),
NextAuthURL: os.Getenv("NEXTAUTH_URL"),
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
AdminEmails: AdminEmails(),
}
}
// Secret returns the signing secret, preferring NEXTAUTH_SECRET then AUTH_SECRET.
// The original code falls back to a hard-coded string; a port must fail instead.
func (c *Config) Secret() string {
if c.NextAuthSecret != "" {
return c.NextAuthSecret
}
return c.AuthSecret
}
// EncryptionSecret returns the key material for at-rest encryption of
// integration credentials (review OPS-01). A dedicated ENCRYPTION_KEY lets
// operators rotate AUTH_SECRET (sessions) without invalidating stored IMAP
// passwords. Without it the auth secret remains the key, matching the
// original derivation, and stored rows keep decrypting via the legacy
// fallback in signals.LoadMailbox.
func (c *Config) EncryptionSecret() string {
if v := os.Getenv("ENCRYPTION_KEY"); v != "" {
return v
}
return c.Secret()
}
// CookieSecure reports whether session cookies must carry the Secure flag.
// An explicit COOKIE_SECURE setting wins; otherwise the NEXTAUTH_URL scheme
// decides, so TLS deployments are secure by default without knowing about
// the extra variable.
func (c *Config) CookieSecure() bool {
if v := os.Getenv("COOKIE_SECURE"); v != "" {
return v == "1"
}
u, err := url.Parse(strings.TrimSpace(c.NextAuthURL))
return err == nil && u.Scheme == "https"
}
// AdminEmails splits env ADMIN_EMAILS on commas, trims, lowercases.
func AdminEmails() []string {
s := os.Getenv("ADMIN_EMAILS")
if s == "" {
return nil
}
var out []string
for _, email := range strings.Split(s, ",") {
if email = strings.ToLower(strings.TrimSpace(email)); email != "" {
out = append(out, email)
}
}
return out
}
// IsAdminEmail reports whether email is listed in ADMIN_EMAILS.
func IsAdminEmail(email string) bool {
e := strings.ToLower(strings.TrimSpace(email))
for _, a := range AdminEmails() {
if strings.ToLower(a) == e {
return true
}
}
return false
}
// AllowBootstrapSignup reports whether the first self-service signup on a
// fresh installation may claim administrator (review SEC-5). Default is on
// so the documented "sign up, then sign in" onboarding keeps working;
// exposed deployments set ALLOW_BOOTSTRAP_SIGNUP=0 and provision operators
// via ADMIN_EMAILS/seed instead, so an attacker scanning for new
// installations cannot claim the instance.
func AllowBootstrapSignup() bool {
return os.Getenv("ALLOW_BOOTSTRAP_SIGNUP") != "0"
}
+50
View File
@@ -0,0 +1,50 @@
package config
import "testing"
func TestCookieSecureDerivation(t *testing.T) {
t.Setenv("COOKIE_SECURE", "")
https := &Config{NextAuthURL: "https://app.example.com"}
if !https.CookieSecure() {
t.Fatal("https NEXTAUTH_URL must imply Secure cookies")
}
local := &Config{NextAuthURL: "http://localhost:3000"}
if local.CookieSecure() {
t.Fatal("http NEXTAUTH_URL must not set Secure")
}
if (&Config{}).CookieSecure() {
t.Fatal("unset NEXTAUTH_URL must not set Secure")
}
t.Setenv("COOKIE_SECURE", "1")
if !local.CookieSecure() {
t.Fatal("explicit COOKIE_SECURE=1 must win over http URL")
}
t.Setenv("COOKIE_SECURE", "0")
if https.CookieSecure() {
t.Fatal("explicit COOKIE_SECURE=0 must win over https URL")
}
}
func TestSecretPrecedence(t *testing.T) {
c := &Config{NextAuthSecret: "next", AuthSecret: "auth"}
if c.Secret() != "next" {
t.Fatal("NEXTAUTH_SECRET must win")
}
if (&Config{AuthSecret: "auth"}).Secret() != "auth" {
t.Fatal("AUTH_SECRET fallback broken")
}
}
// Review OPS-01: a dedicated ENCRYPTION_KEY decouples integration-credential
// encryption from the session signing secret.
func TestEncryptionSecretPrecedence(t *testing.T) {
c := &Config{AuthSecret: "auth"}
t.Setenv("ENCRYPTION_KEY", "")
if c.EncryptionSecret() != "auth" {
t.Fatal("without ENCRYPTION_KEY the auth secret must remain the key")
}
t.Setenv("ENCRYPTION_KEY", "dedicated-key")
if c.EncryptionSecret() != "dedicated-key" {
t.Fatal("ENCRYPTION_KEY must win over the auth secret")
}
}
+155
View File
@@ -0,0 +1,155 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
)
// Encryption of IMAP passwords. Porting guide 3.5.
//
// Ciphertext format (review SEC-9, layout corrected per review CR-20 —
// gcm.Seal emits ciphertext followed by tag):
// v1: base64( version(1) || IV(12) || ciphertext || tag(16) )
// legacy (pre-HKDF): base64( IV(12) || ciphertext || tag(16) ) — no version
// byte; derived with raw SHA-256 for backward compatibility during rotation.
//
// The key is now derived via HKDF-SHA256 with a fixed context string.
// Changing the secret makes saved IMAP passwords unreadable unless the
// legacy fallback is used (handled by Decrypt).
//
// Review CR-20: a legacy record starts with a RANDOM IV byte, which can
// coincidentally equal versionByte — the format cannot be sniffed from the
// first byte alone. Decrypt therefore treats an apparent version byte as a
// hint only: the v1 decode is attempted first and, when GCM authentication
// does not succeed, the WHOLE record is retried as legacy. Only successful
// authenticated decryption ever selects a format/key.
const (
algorithm = "aes-256-gcm"
ivLength = 12
tagLength = 16
versionByte = 1
legacyVersion = 0
hkdfContext = "computer/imap-password/v1"
)
// Key derives the 32-byte AES key from the secret using HKDF-SHA256.
// An empty secret is a programming error: Encrypt/Decrypt guard it, and
// main fails startup without a configured secret.
func Key(secret string) []byte {
if secret == "" {
panic("crypto: empty secret")
}
k, err := hkdf.Key(sha256.New, []byte(secret), nil, hkdfContext, 32)
if err != nil {
panic("hkdf: " + err.Error())
}
return k
}
// LegacyKey derives the 32-byte AES key via raw SHA-256 (pre-HKDF).
// Used by Decrypt when encountering a legacy ciphertext (no version byte).
func LegacyKey(secret string) []byte {
if secret == "" {
panic("crypto: empty secret")
}
h := sha256.Sum256([]byte(secret))
return h[:]
}
// Encrypt returns base64( version(1) || IV(12) || ciphertext || tag(16) ).
// Never returns an error path that loses data; the caller must ensure the
// secret is set.
func Encrypt(text, secret string) (string, error) {
if secret == "" {
return "", errors.New("missing encryption secret")
}
key := Key(secret)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
iv := make([]byte, ivLength)
if _, err := rand.Read(iv); err != nil {
return "", err
}
seal := gcm.Seal(nil, iv, []byte(text), nil)
// gcm output is ciphertext || tag
ciphertext := seal[:len(seal)-tagLength]
tag := seal[len(seal)-tagLength:]
out := make([]byte, 0, 1+ivLength+len(ciphertext)+tagLength)
out = append(out, versionByte)
out = append(out, iv...)
out = append(out, ciphertext...)
out = append(out, tag...)
return base64.StdEncoding.EncodeToString(out), nil
}
// Decrypt reverses Encrypt. Returns an error on malformed input.
// Supports both v1 (HKDF) and legacy (raw SHA-256) ciphertexts for
// transparent rotation. Review CR-20: the leading-byte check is only a hint —
// a legacy IV byte can collide with versionByte, so a failed v1 decode falls
// back to an authenticated legacy decode of the whole record instead of
// declaring the password unreadable.
func Decrypt(data, secret string) (string, error) {
if secret == "" {
return "", errors.New("missing encryption secret")
}
raw, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return "", err
}
if len(raw) >= 1 && raw[0] == versionByte {
if plain, e := openGCM(raw[1:], Key(secret)); e == nil {
return plain, nil
}
// Fall through: either a tampered/miskeyed v1 record (the legacy
// retry will fail authentication too) or a legacy record whose random
// IV starts with the version byte.
}
return openGCM(raw, LegacyKey(secret))
}
// openGCM decodes one record body: IV(12) || ciphertext || tag(16).
func openGCM(raw, key []byte) (string, error) {
if len(raw) < ivLength+tagLength {
return "", errShortInputError
}
iv := raw[:ivLength]
// gcm.Seal returns ciphertext || tag, so the tag is the LAST 16 bytes
tag := raw[len(raw)-tagLength:]
ciphertext := raw[ivLength : len(raw)-tagLength]
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// gcm expects ciphertext || tag
sealed := append([]byte(nil), ciphertext...)
sealed = append(sealed, tag...)
plain, err := gcm.Open(nil, iv, sealed, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
// errShortInputError is returned when the decoded input is too short to contain
// the IV and auth tag.
var errShortInputError = errShortInput{}
type errShortInput struct{}
func (errShortInput) Error() string { return "crypto: input too short" }
+137
View File
@@ -0,0 +1,137 @@
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"testing"
)
func TestPasswordEncryption(t *testing.T) {
cipher, e := Encrypt("mailbox-password", "stable-secret")
if e != nil {
t.Fatal(e)
}
p, e := Decrypt(cipher, "stable-secret")
if e != nil || p != "mailbox-password" {
t.Fatal(p, e)
}
raw, _ := base64.StdEncoding.DecodeString(cipher)
// v1 format: version(1) + IV(12) + ciphertext + tag(16) (review CR-20
// corrected the layout comment: gcm.Seal emits ciphertext || tag).
if len(raw) != 1+12+len(p)+16 {
t.Fatalf("incompatible v1 IV/ciphertext/tag layout: got %d", len(raw))
}
if raw[0] != versionByte {
t.Fatal("ciphertext missing version byte")
}
raw[len(raw)-1] ^= 1
if _, e = Decrypt(base64.StdEncoding.EncodeToString(raw), "stable-secret"); e == nil {
t.Fatal("accepted tampered ciphertext")
}
if _, e = Decrypt(cipher, "changed-secret"); e == nil {
t.Fatal("accepted wrong secret")
}
if _, e = Encrypt("value", ""); e == nil {
t.Fatal("accepted empty secret")
}
if _, e = Decrypt("", "stable-secret"); e == nil {
t.Fatal("accepted empty input")
}
// Too-short bodies are rejected on both format paths.
short := base64.StdEncoding.EncodeToString([]byte{versionByte, 1, 2, 3})
if _, e = Decrypt(short, "stable-secret"); e == nil {
t.Fatal("accepted short versioned input")
}
shortLegacy := base64.StdEncoding.EncodeToString([]byte{9, 9, 9})
if _, e = Decrypt(shortLegacy, "stable-secret"); e == nil {
t.Fatal("accepted short legacy input")
}
}
// legacyEncrypt builds a pre-HKDF ciphertext: base64( IV || ciphertext || tag )
// under the raw SHA-256 key, with full control over the IV (review CR-20 asks
// for deterministic fixtures around the version-byte collision).
func legacyEncrypt(t *testing.T, plain, secret string, iv []byte) string {
t.Helper()
key := sha256.Sum256([]byte(secret))
block, e := aes.NewCipher(key[:])
if e != nil {
t.Fatal(e)
}
gcm, e := cipher.NewGCM(block)
if e != nil {
t.Fatal(e)
}
seal := gcm.Seal(nil, iv, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(append(append([]byte(nil), iv...), seal...))
}
// TestLegacyCiphertextCompatibility verifies Decrypt can read ciphertexts
// created before HKDF/versioning (review SEC-9).
func TestLegacyCiphertextCompatibility(t *testing.T) {
secret := "legacy-secret"
iv := make([]byte, ivLength)
rand.Read(iv)
legacyCipher := legacyEncrypt(t, "legacy-password", secret, iv)
// Decrypt with the new Decrypt (legacy format via LegacyKey/raw SHA-256).
p, e := Decrypt(legacyCipher, secret)
if e != nil || p != "legacy-password" {
t.Fatalf("failed to decrypt legacy ciphertext: %v %v", p, e)
}
// Re-encrypting with the same secret produces v1 format (HKDF + version)
v1Cipher, e := Encrypt(p, secret)
if e != nil {
t.Fatal(e)
}
raw, _ := base64.StdEncoding.DecodeString(v1Cipher)
if raw[0] != versionByte {
t.Fatal("re-encrypted ciphertext missing version byte")
}
}
// TestLegacyVersionByteCollision pins review CR-20: a legacy record whose
// random IV starts with the version byte must still decrypt. The old
// first-byte sniff stripped the byte and selected the v1 key, rendering such
// stored credentials permanently unreadable.
func TestLegacyVersionByteCollision(t *testing.T) {
secret := "legacy-secret"
iv := make([]byte, ivLength)
rand.Read(iv)
iv[0] = versionByte // force the collision, deterministically
legacyCipher := legacyEncrypt(t, "collision-password", secret, iv)
p, e := Decrypt(legacyCipher, secret)
if e != nil || p != "collision-password" {
t.Fatalf("legacy ciphertext colliding with the version byte failed to decrypt: %v %v", p, e)
}
// Tampering must still fail authentication on the fallback path.
raw, _ := base64.StdEncoding.DecodeString(legacyCipher)
raw[len(raw)-1] ^= 1
if _, e = Decrypt(base64.StdEncoding.EncodeToString(raw), secret); e == nil {
t.Fatal("accepted tampered colliding legacy ciphertext")
}
// A wrong secret never authenticates, collision or not.
if _, e = Decrypt(legacyCipher, "other-secret"); e == nil {
t.Fatal("accepted wrong secret for legacy ciphertext")
}
// Sanity: the fixture really starts with the version byte.
if !bytes.HasPrefix(mustDecode(t, legacyCipher), []byte{versionByte}) {
t.Fatal("fixture does not exercise the collision")
}
}
func mustDecode(t *testing.T, s string) []byte {
t.Helper()
raw, e := base64.StdEncoding.DecodeString(s)
if e != nil {
t.Fatal(e)
}
return raw
}
+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) }
+438
View File
@@ -0,0 +1,438 @@
package handlers
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/markdown"
"github.com/andrew/go-computer/internal/preview"
"net/http"
"strconv"
"strings"
"time"
)
func bookmark(r *http.Request, id string, public bool) (object, error) {
if public {
// Review PRIV-04: the public projection is an explicit allowlist and
// carries NO owner attribution — the README promises owner identity
// stays private, and API, share page, and docs must agree. Notes and
// dueDate ARE published by product decision; the share panel states
// exactly what a public link exposes before the owner toggles it.
return db.One(r.Context(), database(r), `SELECT 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",
'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('tag',jsonb_build_object('id',t.id,'name',t.name,'color',t.color))) FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id),'[]'::jsonb))
FROM "Bookmark" b WHERE b.id=$1 AND b."isPublic"`, id)
}
// Review PRIV-02: shared readers get the narrowed allowlist projection;
// only the owner receives the full row with contact and recipient list.
where := `b.id=$1 AND ` + db.BookmarkVisible
uid := user(r)
return db.One(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` || jsonb_build_object('pageLinks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('page',jsonb_build_object('id',p.id,'title',p.title))) FROM "PageBookmark" pb JOIN "Page" p ON p.id=pb."pageId" WHERE pb."bookmarkId"=b.id AND p."ownerId"=$2),'[]'::jsonb)) FROM "Bookmark" b WHERE `+where, id, uid)
}
func HandleBookmarksList(w http.ResponseWriter, r *http.Request) {
args := []any{nil, user(r)}
sp := r.URL.Query()
limit := 200
if raw := sp.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 200 {
respond(w, 0, nil, bad("Limit must be between 1 and 200"))
return
}
limit = n
}
where := `b."ownerId"=$2`
if sp.Get("shared") == "true" {
where = db.BookmarkVisible + ` AND b."ownerId"<>$2`
}
add := func(s string, v any) { args = append(args, v); where += " AND " + fmt.Sprintf(s, len(args)) }
if s := sp.Get("search"); s != "" {
// Escape LIKE wildcards (review PERF-2): an unescaped query pattern
// matched everything and defeated the search's selectivity.
args = append(args, "%"+likeEscaper.Replace(s)+"%")
n := len(args)
where += fmt.Sprintf(` AND (b.title ILIKE $%d ESCAPE '\' OR b.url ILIKE $%d ESCAPE '\' OR b.notes ILIKE $%d ESCAPE '\' OR EXISTS(SELECT 1 FROM "BookmarkTag" bt JOIN "Tag" t ON t.id=bt."tagId" WHERE bt."bookmarkId"=b.id AND t.name ILIKE $%d ESCAPE '\'))`, n, n, n, n)
}
if s := sp.Get("tagId"); s != "" {
add(`EXISTS(SELECT 1 FROM "BookmarkTag" bt WHERE bt."bookmarkId"=b.id AND bt."tagId"=$%d)`, s)
if sp.Get("shared") != "true" {
where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1)
}
}
if s := sp.Get("contactId"); s != "" {
add(`b."contactId"=$%d`, s)
}
if s := sp.Get("kanban"); s != "" {
if !validStatus(s) {
respond(w, 0, nil, bad("Invalid status"))
return
}
add(`b."kanbanStatus"=$%d`, s)
}
if s := sp.Get("date"); s != "" {
v, e := dateValue(s)
if e != nil {
respond(w, 0, nil, e)
return
}
// Pin both sides to UTC: bare ::date casts use the Postgres session
// timezone, which is not pinned and would shift "due on date X".
add(`(b."dueDate" AT TIME ZONE 'UTC')::date=($%d::timestamptz AT TIME ZONE 'UTC')::date`, v)
// Guarded exactly like tagId (review COR-7): re-injecting the
// visibility predicate into a list already widened by it would nest
// BookmarkVisible inside itself.
if sp.Get("shared") != "true" && !strings.Contains(where, db.BookmarkVisible) {
where = strings.Replace(where, `b."ownerId"=$2`, db.BookmarkVisible, 1)
}
}
// Port artifact: db.BookmarkVisible binds the user as $2, so $1 stays a
// permanently-nil placeholder and this predicate is always true. Removing
// it would require renumbering BookmarkVisible's parameters everywhere.
where += ` AND $1::text IS NULL`
if raw := sp.Get("cursor"); raw != "" {
var cursor struct {
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
}
data, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || json.Unmarshal(data, &cursor) != nil || cursor.UpdatedAt.IsZero() || cursor.ID == "" {
respond(w, 0, nil, bad("Invalid cursor"))
return
}
args = append(args, cursor.UpdatedAt, cursor.ID)
where += fmt.Sprintf(` AND (b."updatedAt",b.id)<($%d,$%d)`, len(args)-1, len(args))
}
args = append(args, limit+1)
v, e := db.Many(r.Context(), database(r), `SELECT `+db.BookmarkViewerView+` FROM "Bookmark" b WHERE `+where+fmt.Sprintf(` ORDER BY b."updatedAt" DESC,b.id DESC LIMIT $%d`, len(args)), args...)
if e == nil && len(v) > limit {
v = v[:limit]
last := v[len(v)-1]
cursor, _ := json.Marshal(object{"updatedAt": last["updatedAt"], "id": last["id"]})
w.Header().Set("X-Next-Cursor", base64.RawURLEncoding.EncodeToString(cursor))
}
respond(w, 200, v, e)
}
func validStatus(s string) bool {
return s == "INBOX" || s == "TODO" || s == "IN_PROGRESS" || s == "DONE" || s == "ARCHIVED"
}
func bookmarkFields(b object) (object, error) {
d := fields(b, "url title description notes dueDate alertAt kanbanStatus kanbanColumnId contactId isPublic imapWatchEnabled imapQuery")
if e := stringFields(d, "title description notes kanbanColumnId contactId imapQuery", "url kanbanStatus"); e != nil {
return nil, e
}
for _, l := range []struct {
key string
max int
}{
{"url", maxBookmarkURLLen},
{"title", maxBookmarkTitleLen},
{"description", maxBookmarkDescriptionLen},
{"notes", maxBookmarkNotesLen},
{"imapQuery", maxBookmarkQueryLen},
} {
if e := limitText(d, l.key, l.max); e != nil {
return nil, e
}
}
if e := boolFields(d, "isPublic imapWatchEnabled"); e != nil {
return nil, e
}
if v, ok := d["url"]; ok && !validURL(v.(string)) {
return nil, bad("A valid HTTP(S) URL is required")
}
if s, ok := d["kanbanStatus"]; ok && !validStatus(s.(string)) {
return nil, bad("Invalid status")
}
for _, k := range []string{"dueDate", "alertAt"} {
if v, ok := d[k]; ok {
v, e := dateValue(v)
if e != nil {
return nil, e
}
d[k] = v
if k == "alertAt" {
d["alertSent"] = false
}
}
}
for _, k := range []string{"contactId", "kanbanColumnId"} {
if d[k] == "" {
d[k] = nil
}
}
return d, nil
}
func checkRelations(r *http.Request, tx *sql.Tx, d object, tags []string) error {
for k, table := range map[string]string{"contactId": "Contact", "kanbanColumnId": "KanbanColumn"} {
if v, ok := d[k]; ok && v != nil {
if _, e := owned(r, tx, table, v.(string), "userId"); e != nil {
return bad("Invalid " + k)
}
}
}
// A tag shared WITH you may be attached to your own bookmarks. Note the
// group semantics this implies (by design, see db.BookmarkVisible): the
// bookmark then becomes visible to every recipient of that tag.
for _, tag := range tags {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(t) FROM "Tag" t WHERE id=$1 AND ("ownerId"=$2 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$2))`, tag, user(r)); e != nil {
return bad("Invalid tag")
}
}
return nil
}
func setTags(r *http.Request, tx *sql.Tx, id string, tags []string) error {
if _, e := tx.ExecContext(r.Context(), `DELETE FROM "BookmarkTag" WHERE "bookmarkId"=$1`, id); e != nil {
return e
}
for _, t := range tags {
if _, e := tx.ExecContext(r.Context(), `INSERT INTO "BookmarkTag"("bookmarkId","tagId") VALUES($1,$2)`, id, t); e != nil {
return e
}
}
return nil
}
// addPreview fetches og/favicon metadata for the bookmark's URL through the
// shared preview budget (review CR-06): bookmark mutations and the dedicated
// preview route draw from the SAME per-identity rate and global concurrency
// limits, so saves cannot bypass the route's throttle. Preview work is
// decorative — when the budget is exhausted the bookmark still persists,
// just without fresh og metadata.
func addPreview(ctx context.Context, key string, d object, create bool) {
p := preview.FetchPreview(ctx, key, str(d, "url"))
d["favicon"], d["ogImage"], d["ogTitle"], d["ogDescription"] = p.Favicon, p.OgImage, p.OgTitle, p.OgDescription
if create {
if d["title"] == nil {
if p.OgTitle != nil {
d["title"] = *p.OgTitle
} else {
d["title"] = d["url"]
}
}
if d["description"] == nil {
d["description"] = p.OgDescription
}
}
}
func HandleBookmarksCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := bookmarkFields(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "url") == "" {
respond(w, 0, nil, bad("URL required"))
return
}
tags, e := ids(b, "tagIds")
if e != nil {
respond(w, 0, nil, e)
return
}
id := db.NewID()
d["id"], d["ownerId"] = id, user(r)
addPreview(r.Context(), previewKey(r), d, true)
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// One locking protocol for board mutations (review CR-05): column
// deletion holds lockUser while it snapshots the affected bookmarks,
// moves them and drops the column. Bookmark writes that validate and
// assign kanbanColumnId take the SAME lock first, so a concurrent
// create can no longer land on a column the deletion already counted
// as moved (or escape its move set). Lock order is always user row →
// bookmarks/columns, matching every other lockUser transaction.
if e := lockUser(r, tx); e != nil {
return e
}
if e := checkRelations(r, tx, d, tags); e != nil {
return e
}
row, e := db.Insert(r.Context(), tx, "Bookmark", d)
if e != nil {
return e
}
if e = syncLegacyWatch(r, tx, row); e != nil {
return e
}
return setTags(r, tx, id, tags)
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 201, v, e)
}
func HandleBookmarksGet(w http.ResponseWriter, r *http.Request) {
v, e := bookmark(r, r.PathValue("id"), false)
respond(w, 200, v, e)
}
func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
existing, e := owned(r, database(r), "Bookmark", id, "ownerId")
if e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
d, e := bookmarkFields(b)
if e != nil {
respond(w, 0, nil, e)
return
}
// Optimistic concurrency (review CR-04): the PUT is the owner-driven
// mutation surface (fields AND tag replacement), and concurrency is not
// optional on it — the same DATA-05 rule pages enforce. The client sends
// the version it edited from; a stale base is a 409 and the row is left
// untouched, so overlapping edits can no longer silently overwrite each
// other. Sharing (POST /share) and server-driven flags (alertSent) are
// deliberately outside this contract; the kanban column-deletion move
// bumps the version itself.
baseVersion, hasVersion := 0, false
if v, present := b["version"]; present {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
if !hasVersion {
respond(w, 0, nil, bad("version is required when updating a bookmark; refetch it and retry"))
return
}
tags, e := ids(b, "tagIds")
if e != nil {
respond(w, 0, nil, e)
return
}
if s := str(d, "url"); s != "" && s != str(existing, "url") {
addPreview(r.Context(), previewKey(r), d, false)
}
d["updatedAt"] = time.Now().UTC()
d["version"] = baseVersion + 1
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// Review CR-05: same board-mutation locking protocol as create and
// column deletion — placement is validated (checkRelations) and
// written under the user lock, so a column deletion can neither race
// this assignment nor move a bookmark this write just placed.
if e := lockUser(r, tx); e != nil {
return e
}
// Re-read under the lock: the version check must see the latest
// committed state, not the pre-transaction snapshot.
cur, e := owned(r, tx, "Bookmark", id, "ownerId")
if e != nil {
return e
}
if stored, _ := cur["version"].(float64); int(stored) != baseVersion {
return conflict("Bookmark was modified elsewhere; reload to see the latest version")
}
if e := checkRelations(r, tx, d, tags); e != nil {
return e
}
row, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), d)
if e != nil {
return e
}
if e = syncLegacyWatch(r, tx, row); e != nil {
return e
}
if _, ok := b["tagIds"]; ok {
return setTags(r, tx, id, tags)
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 200, v, e)
}
func HandleBookmarksDelete(w http.ResponseWriter, r *http.Request) {
e := remove(r, "Bookmark", r.PathValue("id"), "ownerId")
respond(w, 200, OK{true}, e)
}
// The original mailbox-watch switch now drives an actual email signal source.
// Modern watchers added in the Signals panel are managed independently.
func syncLegacyWatch(r *http.Request, tx *sql.Tx, b object) error {
enabled, _ := b["imapWatchEnabled"].(bool)
raw, _ := json.Marshal(object{"subject": str(b, "imapQuery"), "legacy": true})
res, e := tx.ExecContext(r.Context(), `UPDATE "SignalSource" SET config=$1::json,enabled=$2,"updatedAt"=now() WHERE "bookmarkId"=$3 AND "ownerId"=$4 AND config->>'legacy'='true'`, string(raw), enabled, b["id"], user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 && enabled {
_, e = db.Insert(r.Context(), tx, "SignalSource", object{"id": db.NewID(), "type": "email", "config": string(raw), "enabled": true, "bookmarkId": b["id"], "ownerId": user(r)})
}
return e
}
func HandleBookmarksShare(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
list, e := ids(b, "userIds")
if e != nil {
respond(w, 0, nil, e)
return
}
if e = boolFields(b, "isPublic"); e != nil {
respond(w, 0, nil, e)
return
}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := shareRows(r, tx, "BookmarkShare", "bookmarkId", id, list); e != nil {
return e
}
if _, ok := b["isPublic"]; ok {
_, e := db.Update(r.Context(), tx, "Bookmark", id, "ownerId", user(r), object{"isPublic": b["isPublic"], "updatedAt": time.Now().UTC()})
return e
}
return nil
})
respond(w, 200, OK{true}, e)
}
func HandleBookmarksMarkdown(w http.ResponseWriter, r *http.Request) {
// bookmark() already applies the permission-specific projection (review
// PRIV-02), so shared readers export only fields they may read.
v, e := bookmark(r, r.PathValue("id"), false)
if e != nil {
respond(w, 0, nil, e)
return
}
markdown.Send(w, markdown.BookmarkDocument(v), str(v, "title"))
}
// previewKey is the shared-budget identity for preview work (review CR-06):
// per user, across every entry point (route + mutations).
func previewKey(r *http.Request) string { return "user:" + user(r) }
func HandlePreview(w http.ResponseWriter, r *http.Request) {
u := r.URL.Query().Get("url")
if !validURL(u) {
respond(w, 0, nil, bad("A valid HTTP(S) URL is required"))
return
}
respond(w, 200, preview.FetchPreview(r.Context(), previewKey(r), u), nil)
}
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/db"
"github.com/jackc/pgx/v5/pgconn"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"unicode/utf8"
)
type object = db.Object
type clientError struct{ message string }
func (e clientError) Error() string { return e.message }
func bad(s string) error { return clientError{s} }
// conflictError maps to 409: the request collides with newer server state
// (optimistic concurrency, review DATA-01), not with a unique constraint.
type conflictError struct{ message string }
func (e conflictError) Error() string { return e.message }
func conflict(s string) error { return conflictError{s} }
func database(r *http.Request) *db.DB { return db.FromContext(r.Context()) }
func user(r *http.Request) string { s, _ := auth.SessionFromContext(r.Context()); return s.ID }
func read(w http.ResponseWriter, r *http.Request) (object, bool) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var b object
dec := json.NewDecoder(r.Body)
if dec.Decode(&b) != nil || b == nil {
writeJSON(w, 400, Error{"Invalid JSON object"})
return nil, false
}
var extra any
if dec.Decode(&extra) != io.EOF {
writeJSON(w, 400, Error{"Unexpected data after JSON"})
return nil, false
}
return b, true
}
func respond(w http.ResponseWriter, status int, v any, e error) {
if e != nil {
code, msg := 500, "Server error"
var ce clientError
var cfe conflictError
var pg *pgconn.PgError
switch {
case errors.Is(e, db.ErrNotFound):
code, msg = 404, "Not found"
case errors.As(e, &ce):
code, msg = 400, ce.message
case errors.As(e, &cfe):
code, msg = 409, cfe.message
case errors.As(e, &pg) && pg.Code == "23505":
code, msg = 409, "Already exists"
case errors.As(e, &pg) && (pg.Code == "23503" || pg.Code == "22P02"):
code, msg = 400, "Invalid related record or value"
default:
log.Printf("request: %v", e)
}
writeJSON(w, code, Error{msg})
return
}
writeJSON(w, status, v)
}
func str(b object, k string) string { v, _ := b[k].(string); return v }
func ids(b object, k string) ([]string, error) {
v, ok := b[k]
if !ok {
return nil, nil
}
list, ok := v.([]any)
if !ok {
return nil, bad(k + " must be an array")
}
out := []string{}
seen := map[string]bool{}
for _, a := range list {
s, ok := a.(string)
if !ok || s == "" {
return nil, bad("Invalid " + k)
}
if !seen[s] {
out = append(out, s)
seen[s] = true
} else {
return nil, bad("Duplicate " + k)
}
}
return out, nil
}
func fields(b object, allowed string) object {
out := object{}
for _, k := range strings.Fields(allowed) {
if v, ok := b[k]; ok {
out[k] = v
}
}
return out
}
func owned(r *http.Request, q db.Queryer, table, id, col string) (object, error) {
return db.One(r.Context(), q, `SELECT to_jsonb(t) FROM `+db.Quote(table)+` t WHERE id=$1 AND `+db.Quote(col)+`=$2`, id, user(r))
}
func remove(r *http.Request, table, id, col string) error {
res, e := database(r).ExecContext(r.Context(), `DELETE FROM `+db.Quote(table)+` WHERE id=$1 AND `+db.Quote(col)+`=$2`, id, user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 {
return db.ErrNotFound
}
return nil
}
func dateValue(v any) (any, error) {
if v == nil {
return nil, nil
}
s, ok := v.(string)
if !ok {
return nil, bad("Invalid date")
}
if s == "" {
return nil, nil
}
for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02"} {
if t, e := time.Parse(layout, s); e == nil {
return t.UTC(), nil
}
}
return nil, bad("Invalid date")
}
var colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
func validColor(b object) error {
if v, ok := b["color"]; ok {
if s, ok := v.(string); !ok || !colorPattern.MatchString(s) {
return bad("Color must be #RRGGBB")
}
}
return nil
}
func validURL(s string) bool {
u, e := url.Parse(s)
return e == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.User == nil
}
func stringFields(b object, nullable, required string) error {
for _, k := range strings.Fields(nullable) {
if v, ok := b[k]; ok && v != nil {
if _, ok := v.(string); !ok {
return bad(k + " must be text")
}
}
}
for _, k := range strings.Fields(required) {
if v, ok := b[k]; ok {
if _, ok := v.(string); !ok {
return bad(k + " must be text")
}
}
}
return nil
}
// Per-field size limits (review "cap input sizes"). The 1 MiB request-body
// cap alone still lets a title, tag name or URL grow absurdly large, which
// bloats every list response and search index. These are deliberately
// generous product limits, not storage constraints; over-limit input is
// rejected with a 400 instead of being silently truncated so a client never
// believes it saved something it did not.
const (
maxBookmarkURLLen = 2048
maxBookmarkTitleLen = 500
maxBookmarkDescriptionLen = 2000
maxBookmarkNotesLen = 100000
maxBookmarkQueryLen = 500
maxPageContentLen = 512 << 10
maxPageIconLen = 64
maxTagNameLen = 80
maxSharedDateNoteLen = 2000
maxContactNameLen = 200
maxContactEmailLen = 320
maxContactPhoneLen = 64
maxContactUIDLen = 255
maxMailboxHostLen = 255
maxMailboxUsernameLen = 320
maxMailboxPasswordLen = 4096
maxMailboxFolderLen = 255
)
// limitText rejects a text field longer than max runes. Missing and null
// fields are fine; a non-string value is reported by stringFields first.
func limitText(d object, key string, max int) error {
v, ok := d[key]
if !ok || v == nil {
return nil
}
s, ok := v.(string)
if !ok {
return bad(key + " must be text")
}
if utf8.RuneCountInString(s) > max {
return bad(fmt.Sprintf("%s must be at most %d characters", key, max))
}
return nil
}
func boolFields(b object, keys string) error {
for _, k := range strings.Fields(keys) {
if v, ok := b[k]; ok {
if _, ok := v.(bool); !ok {
return bad(k + " must be boolean")
}
}
}
return nil
}
func lockUser(r *http.Request, tx *sql.Tx) error {
_, e := tx.ExecContext(r.Context(), `SELECT id FROM "User" WHERE id=$1 FOR UPDATE`, user(r))
return e
}
func reorder(r *http.Request, table, col string, order []string) error {
return db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
var count int
if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM `+db.Quote(table)+` WHERE `+db.Quote(col)+`=$1`, user(r)).Scan(&count); e != nil {
return e
}
if count != len(order) {
return bad("Order must contain every record exactly once")
}
for i, id := range order {
res, e := tx.ExecContext(r.Context(), `UPDATE `+db.Quote(table)+` SET position=$1 WHERE id=$2 AND `+db.Quote(col)+`=$3`, i, id, user(r))
if e != nil {
return e
}
n, _ := res.RowsAffected()
if n == 0 {
return bad("Invalid order")
}
}
return nil
})
}
func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error {
for _, u := range list {
if _, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") VALUES($1,$2) ON CONFLICT DO NOTHING`, id, u); e != nil {
return e
}
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package handlers
import (
"encoding/json"
"net/http"
)
// Common helpers shared by all handlers. Porting guide section 4.
//
// Every data endpoint follows the same pattern: resolve the effective user,
// return 401 if none, check ownership with a filtered lookup, return 404 if
// the record is not theirs, then act.
//
// All routes return JSON unless marked .md. Errors are {error: string}.
// OK is the success response shape for mutations.
type OK struct {
OK bool `json:"ok"`
}
// Error is the error response shape.
type Error struct {
Error string `json:"error"`
}
// NotFound returns a 404 {error} response.
func NotFound(message string) Error {
if message == "" {
message = "not found"
}
return Error{Error: message}
}
// Unauthorized returns a 401 {error} response.
func Unauthorized(message string) Error {
if message == "" {
message = "unauthorized"
}
return Error{Error: message}
}
// Forbidden returns a 403 {error} response.
func Forbidden(message string) Error {
if message == "" {
message = "forbidden"
}
return Error{Error: message}
}
// BadRequest returns a 400 {error} response.
func BadRequest(message string) Error {
if message == "" {
message = "bad request"
}
return Error{Error: message}
}
// Conflict returns a 409 {error} response.
func Conflict(message string) Error {
return Error{Error: message}
}
// WriteJSON writes v as JSON with the given status.
func WriteJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeJSON is the lowercase alias used by the stub handlers.
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
WriteJSON(w, status, v)
}
+269
View File
@@ -0,0 +1,269 @@
package handlers
import (
"database/sql"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/kanban"
"net/http"
"strings"
)
func HandleKanbanColumnsList(w http.ResponseWriter, r *http.Request) {
v, e := kanban.GetColumns(r.Context(), database(r), user(r))
respond(w, 200, v, e)
}
func columnData(b object) (object, error) {
d := fields(b, "label color")
if e := stringFields(d, "", "label color"); e != nil {
return nil, e
}
if _, ok := d["label"]; ok {
s := strings.TrimSpace(str(d, "label"))
if s == "" {
return nil, bad("Label required")
}
r := []rune(s)
if len(r) > 40 {
r = r[:40]
}
d["label"] = string(r)
}
return d, validColor(d)
}
func HandleKanbanColumnsCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := columnData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "label") == "" {
respond(w, 0, nil, bad("Label required"))
return
}
if _, ok := d["color"]; !ok {
d["color"] = "#8b5cf6"
}
d["id"], d["userId"] = db.NewID(), user(r)
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
var n int
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "KanbanColumn" WHERE "userId"=$1`, user(r)).Scan(&n); e != nil {
return e
}
d["position"] = n
var e error
v, e = db.Insert(r.Context(), tx, "KanbanColumn", d)
return e
})
respond(w, 201, v, e)
}
func HandleKanbanColumnsReorder(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
order, e := ids(b, "order")
if e == nil {
e = reorder(r, "KanbanColumn", "userId", order)
}
if e != nil {
respond(w, 0, nil, e)
return
}
HandleKanbanColumnsList(w, r)
}
func HandleKanbanColumnsUpdate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := columnData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := db.Update(r.Context(), database(r), "KanbanColumn", r.PathValue("id"), "userId", user(r), d)
respond(w, 200, v, e)
}
// HandleKanbanCardsMove moves or reorders one card on the board. It carries
// the same optimistic-concurrency version as a bookmark PUT (review CR-04):
// a card edited or moved elsewhere is a 409, and the board reloads instead of
// silently clobbering it. The target column's positions are normalized in the
// same transaction, so dragging inside a column survives reloads.
func HandleKanbanCardsMove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
b, ok := read(w, r)
if !ok {
return
}
columnID := strings.TrimSpace(str(b, "columnId"))
if columnID == "" {
respond(w, 0, nil, bad("columnId is required"))
return
}
rawPosition, hasPosition := b["position"]
if !hasPosition {
respond(w, 0, nil, bad("position is required"))
return
}
f, ok := rawPosition.(float64)
if !ok || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("position must be a non-negative integer"))
return
}
position := int(f)
baseVersion, hasVersion := 0, false
if v, present := b["version"]; present {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
if !hasVersion {
respond(w, 0, nil, bad("version is required when moving a card; refetch it and retry"))
return
}
// Ensure the default board exists before the transaction reads columns.
if _, e := kanban.GetColumns(r.Context(), database(r), user(r)); e != nil {
respond(w, 0, nil, e)
return
}
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
cur, e := owned(r, tx, "Bookmark", id, "ownerId")
if e != nil {
return e
}
if stored, _ := cur["version"].(float64); int(stored) != baseVersion {
return conflict("Bookmark was modified elsewhere; reload to see the latest version")
}
if _, e := owned(r, tx, "KanbanColumn", columnID, "userId"); e != nil {
return bad("Invalid columnId")
}
cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r))
if e != nil {
return e
}
// Resolve legacy cards (kanbanStatus only) the same way the board does,
// then normalize every card in the target column to explicit
// membership plus a contiguous position. That makes the new order
// durable without requiring a schema backfill.
rows, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE b."ownerId"=$1 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
if e != nil {
return e
}
target := make([]string, 0, len(rows))
for _, row := range rows {
if str(row, "id") == id {
continue
}
if kanban.ResolveColumnID(row, cols) == columnID {
target = append(target, str(row, "id"))
}
}
if position > len(target) {
position = len(target)
}
target = append(target, "")
copy(target[position+1:], target[position:])
target[position] = id
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanColumnId"=$1,"kanbanPosition"=t.ord-1 FROM unnest($2::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$3`, columnID, target, user(r)); e != nil {
return e
}
// Only the moved card's revision advances; reindexing its neighbours is
// board placement, not a content edit.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET version=version+1,"updatedAt"=now() WHERE id=$1 AND "ownerId"=$2`, id, user(r)); e != nil {
return e
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 200, v, e)
}
func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var dest string
var moved int
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// The user row is the single serialization point for board mutations
// (review CR-05): bookmark create/update take the same lock before
// validating/assigning kanbanColumnId, so this handler's snapshot of
// affected bookmarks, the move and the column delete cannot interleave
// with a competing assignment.
if e := lockUser(r, tx); e != nil {
return e
}
cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r))
if e != nil {
return e
}
found := false
remaining := []object{}
for _, c := range cols {
if str(c, "id") == id {
found = true
} else {
remaining = append(remaining, c)
}
}
if !found {
return db.ErrNotFound
}
if len(remaining) == 0 {
return bad("A board needs at least one column")
}
dest = str(remaining[0], "id")
// Only the three columns ResolveColumnID needs, and one set-based
// UPDATE for the whole move instead of a round trip per bookmark.
bms, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE "ownerId"=$1`, user(r))
if e != nil {
return e
}
moveIDs := make([]string, 0, len(bms))
for _, b := range bms {
if kanban.ResolveColumnID(b, cols) == id {
moveIDs = append(moveIDs, str(b, "id"))
}
}
if len(moveIDs) > 0 {
// The move is a mutation covered by the bookmark concurrency
// contract (review CR-04): bumping version turns any client
// snapshot that still shows the old column into a 409 on its
// next save instead of letting it silently move the bookmark
// back.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[])`, dest, moveIDs); e != nil {
return e
}
moved = len(moveIDs)
}
if _, e := tx.ExecContext(r.Context(), `DELETE FROM "KanbanColumn" WHERE id=$1 AND "userId"=$2`, id, user(r)); e != nil {
return e
}
order := make([]string, len(remaining))
for i, c := range remaining {
order[i] = str(c, "id")
}
if _, e := tx.ExecContext(r.Context(), `UPDATE "KanbanColumn" AS k SET position=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(cid,ord) WHERE k.id=t.cid`, order); e != nil {
return e
}
return nil
})
respond(w, 200, object{"ok": true, "movedTo": dest, "moved": moved}, e)
}
+560
View File
@@ -0,0 +1,560 @@
package handlers
import (
"context"
"database/sql"
"encoding/xml"
"errors"
"github.com/andrew/go-computer/internal/config"
crypt "github.com/andrew/go-computer/internal/crypto"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/netguard"
"github.com/andrew/go-computer/internal/signals"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// carddavClient dials through netguard so a user-supplied Nextcloud URL can
// never point the server at internal infrastructure (same guard as URL
// previews). The transport is shared to avoid leaking idle sockets per sync.
var carddavClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{DialContext: netguard.DialContext},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
func HandleAlertsCheck(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "alertAt"<=now()+interval '15 minutes' AND NOT "alertSent" ORDER BY "alertAt"`, user(r))
respond(w, 200, v, e)
}
func HandleAlertsDismiss(w http.ResponseWriter, r *http.Request) {
_, e := db.Update(r.Context(), database(r), "Bookmark", r.PathValue("bookmarkId"), "ownerId", user(r), object{"alertSent": true, "updatedAt": time.Now().UTC()})
respond(w, 200, OK{true}, e)
}
func HandleUsersSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if len([]rune(q)) < 2 {
respond(w, 200, []object{}, nil)
return
}
// The share picker needs names and IDs; substring matching on emails would
// let any user enumerate the whole user base, so emails must match exactly
// and are returned masked. The name pattern escapes LIKE wildcards
// (review SEC-3): without that, "%%" or "a%" enumerated every user
// 10 rows at a time.
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',id,'name',name,'email',CASE WHEN position('@' in email)=0 THEN NULL ELSE left(email,1)||'***'||substr(email, position('@' in email)) END,'image',image) FROM "User" WHERE id<>$1 AND (name ILIKE $2 ESCAPE '\' OR lower(email)=lower($3)) ORDER BY name LIMIT 10`, user(r), "%"+likeEscaper.Replace(q)+"%", q)
respond(w, 200, v, e)
}
func HandleSharedDatesList(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s)||jsonb_build_object('creator',jsonb_build_object('id',c.id,'name',c.name,'email',c.email),'recipient',jsonb_build_object('id',u.id,'name',u.name,'email',u.email)) FROM "SharedDate" s JOIN "User" c ON c.id=s."creatorId" JOIN "User" u ON u.id=s."recipientId" WHERE s."creatorId"=$1 OR s."recipientId"=$1 ORDER BY date`, user(r))
respond(w, 200, v, e)
}
func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
dt, e := dateValue(b["date"])
if e != nil || dt == nil {
respond(w, 0, nil, bad("Date required"))
return
}
list, e := ids(b, "recipientIds")
if e != nil || len(list) == 0 {
respond(w, 0, nil, bad("Recipients required"))
return
}
if e = stringFields(b, "note", ""); e != nil {
respond(w, 0, nil, e)
return
}
if e = limitText(b, "note", maxSharedDateNoteLen); e != nil {
respond(w, 0, nil, e)
return
}
out := []object{}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
for _, id := range list {
v, e := db.Insert(r.Context(), tx, "SharedDate", object{"id": db.NewID(), "date": dt, "creatorId": user(r), "recipientId": id, "note": b["note"]})
if e != nil {
return e
}
out = append(out, v)
}
return nil
})
respond(w, 201, out, e)
}
func HandleContactsList(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "Bookmark" b WHERE b."contactId"=c.id AND b."ownerId"=$1))) FROM "Contact" c WHERE "userId"=$1 ORDER BY "displayName"`, user(r))
respond(w, 200, v, e)
}
type davResponse struct {
Responses []struct {
Href string `xml:"href"`
Propstats []struct {
Status string `xml:"status"`
Prop struct {
Data string `xml:"address-data"`
} `xml:"prop"`
} `xml:"propstat"`
} `xml:"response"`
}
// resolveCardDAVTarget resolves the user-supplied cardDavPath against the
// configured base URL and revalidates the COMPLETE resolved destination
// (review CR-01): a same-host absolute path like "http://host/..." must not
// downgrade an https base, because Basic-auth credentials are attached to the
// resulting request. The resolved URL must keep the base's scheme and host
// and carry no embedded userinfo. The scheme check on the base URL itself
// (https unless the netguard fixture flag is set) happens at the caller, so
// pinning the resolved scheme to the base scheme enforces https end to end.
func resolveCardDAVTarget(base *url.URL, path string) (*url.URL, error) {
target, e := base.Parse(path)
if e != nil {
return nil, bad("Invalid CardDAV path")
}
if target.Host != base.Host || target.Scheme != base.Scheme || target.User != nil {
return nil, bad("CardDAV path must stay on the Nextcloud host and scheme")
}
return target, nil
}
func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
if !validURL(str(b, "nextcloudUrl")) || str(b, "username") == "" || str(b, "password") == "" {
respond(w, 0, nil, bad("Nextcloud URL and credentials required"))
return
}
base, _ := url.Parse(str(b, "nextcloudUrl"))
// CardDAV transport (review recheck): Basic-auth credentials must never
// travel plaintext. https is required unless the netguard escape hatch is
// set (loopback test fakes / deliberate LAN deployments — never
// production Compose). Redirects are not followed at all
// (ErrUseLastResponse), so a verified origin cannot be downgraded, and
// the path resolution below is pinned to the same host AND the same
// scheme (review CR-01: the final resolved destination is revalidated,
// not just the configured base URL).
if base.Scheme != "https" && !netguard.AllowPrivate() {
respond(w, 0, nil, bad("Nextcloud URL must use https"))
return
}
path := str(b, "cardDavPath")
if path == "" {
path = "/remote.php/dav/addressbooks/users/" + url.PathEscape(str(b, "username")) + "/contacts/"
}
target, e := resolveCardDAVTarget(base, path)
if e != nil {
respond(w, 0, nil, e)
return
}
payload := `<?xml version="1.0"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>`
req, e := http.NewRequestWithContext(r.Context(), "REPORT", target.String(), strings.NewReader(payload))
if e != nil {
respond(w, 0, nil, bad("Invalid CardDAV URL"))
return
}
req.SetBasicAuth(str(b, "username"), str(b, "password"))
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml")
res, e := carddavClient.Do(req)
if e != nil {
respond(w, 0, nil, bad("Could not connect to Nextcloud"))
return
}
defer res.Body.Close()
if res.StatusCode != 207 && res.StatusCode != 200 {
respond(w, 0, nil, bad("Nextcloud rejected the CardDAV request"))
return
}
var dav davResponse
if e = xml.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(&dav); e != nil {
respond(w, 0, nil, bad("Invalid CardDAV response"))
return
}
synced := 0
removed := 0
seenUIDs := []string{}
// complete tracks whether the server answered with full data for every
// card. Deletion reconciliation (review CR-07) runs ONLY on a complete,
// non-empty snapshot: a failed, truncated or partially-withheld response
// must never be read as "these contacts were deleted remotely".
complete := true
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
for _, entry := range dav.Responses {
gotData := false
for _, prop := range entry.Propstats {
if !strings.Contains(prop.Status, "200") {
continue
}
v := parseVCard(prop.Prop.Data)
uid, fn := vCardPick(v["UID"]), vCardPick(v["FN"])
if uid == "" || fn == "" {
// A card we cannot identify stays untouched locally — and
// blocks reconciliation, since we cannot prove which
// stored contact (if any) it corresponds to.
complete = false
continue
}
// Contact data comes from a remote server, so apply the same
// size discipline as user input. A UID is an identity key and
// must never be truncated; skip an absurd one and keep the
// snapshot incomplete so reconciliation stays conservative.
if len([]rune(uid)) > maxContactUIDLen {
complete = false
continue
}
gotData = true
seenUIDs = append(seenUIDs, uid)
email := trimLimit(vCardPick(v["EMAIL"]), maxContactEmailLen)
phone := trimLimit(vCardPick(v["TEL"]), maxContactPhoneLen)
fn = trimLimit(fn, maxContactNameLen)
_, e := tx.ExecContext(r.Context(), `INSERT INTO "Contact"(id,uid,"displayName",email,phone,"cardDavUrl",source,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(uid,"userId") DO UPDATE SET "displayName"=EXCLUDED."displayName",email=EXCLUDED.email,phone=EXCLUDED.phone,"cardDavUrl"=EXCLUDED."cardDavUrl",source=EXCLUDED.source,"updatedAt"=now()`, db.NewID(), uid, fn, nullable(email), nullable(phone), entry.Href, target.String(), user(r))
if e != nil {
return e
}
synced++
}
if !gotData {
complete = false
}
}
// Remote-deletion reconciliation (review CR-07). Policy: the sync
// MIRRORS the configured address book, scoped by provenance — only
// contacts previously imported from THIS source URL are candidates,
// manually created contacts (source NULL) and other address books'
// imports are never touched, and bookmarks referencing a removed
// contact keep working (contactId is ON DELETE SET NULL). Guards:
// a non-empty, fully-answered snapshot only; an empty or partial
// response preserves everything (a genuinely emptied address book
// stops mirroring deletions until it has at least one card — the
// deliberate safe-side tradeoff).
if complete && synced > 0 {
res, e := tx.ExecContext(r.Context(), `DELETE FROM "Contact" WHERE "userId"=$1 AND source=$2 AND NOT (uid = ANY($3::text[]))`, user(r), target.String(), seenUIDs)
if e != nil {
return e
}
n, _ := res.RowsAffected()
removed = int(n)
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
respond(w, 200, object{"synced": synced, "removed": removed}, nil)
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
// vCardValue is one parsed property occurrence: its parameters (TYPE, PREF,
// ENCODING, ...) and its unescaped value. Repeated properties keep EVERY
// occurrence in file order (review CR-08) instead of the last one clobbering
// the rest.
type vCardValue struct {
params map[string][]string
value string
}
// splitVCardName splits the property name on unquoted semicolons so quoted
// parameter values ("...;X=a;b") survive.
func splitVCardName(name string) []string {
out := []string{}
var cur strings.Builder
inQuotes := false
for _, r := range name {
switch {
case r == '"':
inQuotes = !inQuotes
cur.WriteRune(r)
case r == ';' && !inQuotes:
out = append(out, cur.String())
cur.Reset()
default:
cur.WriteRune(r)
}
}
return append(out, cur.String())
}
// parseVCard parses the documented subset of vCard (RFC 2426/6350) the
// contact sync consumes: CRLF and bare-LF unfolding, group prefixes
// (item1.EMAIL), parameter lists (including bare vCard 2.1 tokens as
// implicit TYPE values), and the standard escapes. Unsupported data is
// dropped, not silently misparsed (review CR-08): properties declaring a
// base64 ENCODING are skipped — the stored fields (UID/FN/EMAIL/TEL) are
// never legitimately encoded, and keeping the raw text would pretend to be
// a correct parse. A colon inside a quoted parameter value is outside the
// supported subset.
func parseVCard(s string) map[string][]vCardValue {
s = strings.ReplaceAll(s, "\r\n ", "")
s = strings.ReplaceAll(s, "\r\n\t", "")
s = strings.ReplaceAll(s, "\n ", "")
s = strings.ReplaceAll(s, "\n\t", "")
out := map[string][]vCardValue{}
for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
fields := splitVCardName(parts[0])
key := strings.ToUpper(fields[0])
if i := strings.LastIndex(key, "."); i >= 0 {
key = key[i+1:] // strip group prefix (item1.EMAIL -> EMAIL)
}
if key == "" {
continue
}
params := map[string][]string{}
for _, p := range fields[1:] {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 {
// Bare token (vCard 2.1 style "EMAIL;INTERNET;WORK"): an
// implicit TYPE value.
if tok := strings.TrimSpace(kv[0]); tok != "" {
params["TYPE"] = append(params["TYPE"], strings.ToUpper(tok))
}
continue
}
pk := strings.ToUpper(strings.TrimSpace(kv[0]))
for _, pv := range strings.Split(kv[1], ",") {
params[pk] = append(params[pk], strings.ToUpper(strings.Trim(strings.TrimSpace(pv), `"`)))
}
}
if enc := params["ENCODING"]; len(enc) > 0 && (enc[0] == "B" || enc[0] == "BASE64") {
continue // unsupported encoding: drop instead of misparsing
}
value := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`).Replace(parts[1])
out[key] = append(out[key], vCardValue{params: params, value: value})
}
return out
}
// vCardPick deterministically selects the display value among repeated
// occurrences (review CR-08): the lowest explicit PREF=n wins (vCard 4.0),
// then the first TYPE=PREF occurrence (vCard 3.0), then file order.
func vCardPick(values []vCardValue) string {
if len(values) == 0 {
return ""
}
best := values[0].value
bestPref := 1 << 30
for _, v := range values {
for _, p := range v.params["PREF"] {
if n, e := strconv.Atoi(p); e == nil && n < bestPref {
bestPref, best = n, v.value
}
}
}
if bestPref != 1<<30 {
return best
}
for _, v := range values {
for _, p := range v.params["TYPE"] {
if p == "PREF" {
return v.value
}
}
}
return best
}
func mailboxData(b object) (signals.Mailbox, error) {
if e := stringFields(b, "", "host username password folder"); e != nil {
return signals.Mailbox{}, e
}
if e := boolFields(b, "tls"); e != nil {
return signals.Mailbox{}, e
}
for _, l := range []struct {
key string
max int
}{
{"host", maxMailboxHostLen},
{"username", maxMailboxUsernameLen},
{"password", maxMailboxPasswordLen},
{"folder", maxMailboxFolderLen},
} {
if e := limitText(b, l.key, l.max); e != nil {
return signals.Mailbox{}, e
}
}
m := signals.Mailbox{Host: str(b, "host"), Username: str(b, "username"), Password: str(b, "password"), Folder: str(b, "folder"), Port: 993, TLS: true}
if n, ok := b["port"]; ok {
v, ok := n.(float64)
if !ok || v < 1 || v > 65535 || float64(int(v)) != v {
return m, bad("Invalid port")
}
m.Port = int(v)
}
if v, ok := b["tls"].(bool); ok {
m.TLS = v
}
// Normal IMAP ports only (review "outbound fetches aren't limited to
// normal ports"). The fixture flags keep loopback fakes and deliberate
// LAN deployments working on an ephemeral port.
if !signals.AllowInsecure() && !netguard.AllowPrivate() && m.Port != 993 && m.Port != 143 {
return m, bad("Mailbox port must be 143 or 993")
}
// Review NET-02: mailbox credentials must never travel over a plaintext
// socket. Insecure settings are rejected at the API boundary; the
// development-only INTEGRATIONS_ALLOW_INSECURE_IMAP escape hatch mirrors
// netguard's fixture flag and is never set in production Compose.
if !m.TLS && !signals.AllowInsecure() {
return m, bad("TLS is required for mailbox connections")
}
if m.Folder == "" {
m.Folder = "INBOX"
}
if m.Host == "" || m.Username == "" {
return m, bad("Host and username required")
}
return m, nil
}
func HandleImapGet(w http.ResponseWriter, r *http.Request) {
v, e := db.One(r.Context(), database(r), `SELECT to_jsonb(m)-'password'||jsonb_build_object('password','••••••••') FROM "ImapConfig" m WHERE "userId"=$1`, user(r))
if e == db.ErrNotFound {
respond(w, 200, nil, nil)
return
}
respond(w, 200, v, e)
}
func HandleImapSave(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
m, e := mailboxData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
// Review CR-21: a blank/masked password means "keep the stored secret".
// The old path read the existing ciphertext and re-submitted it in the
// upsert, so a password saved concurrently between that read and write
// was silently rolled back. The keep-stored path therefore never assigns
// the password column at all — whatever is stored at commit time wins.
if m.Password == "" || m.Password == "••••••••" {
res, e := database(r).ExecContext(r.Context(), `UPDATE "ImapConfig" SET host=$1,port=$2,tls=$3,username=$4,folder=$5 WHERE "userId"=$6`, m.Host, m.Port, m.TLS, m.Username, m.Folder, user(r))
if e != nil {
respond(w, 0, nil, e)
return
}
if n, _ := res.RowsAffected(); n == 0 {
// No configuration exists to keep a password from (or it was
// removed concurrently): initial creation requires a real one.
respond(w, 0, nil, bad("Password required"))
return
}
respond(w, 200, OK{true}, nil)
return
}
pw, e := crypt.Encrypt(m.Password, config.Load().EncryptionSecret())
if e != nil {
respond(w, 0, nil, e)
return
}
_, e = database(r).ExecContext(r.Context(), `INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT("userId") DO UPDATE SET host=EXCLUDED.host,port=EXCLUDED.port,tls=EXCLUDED.tls,username=EXCLUDED.username,password=EXCLUDED.password,folder=EXCLUDED.folder`, db.NewID(), m.Host, m.Port, m.TLS, m.Username, pw, m.Folder, user(r))
respond(w, 200, OK{true}, e)
}
func HandleImapTest(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
m, e := mailboxData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if m.Password == "" || m.Password == "••••••••" {
saved, e := signals.LoadMailbox(r.Context(), database(r), user(r))
if errors.Is(e, db.ErrNotFound) {
respond(w, 0, nil, bad("Password required"))
return
}
if e != nil {
respond(w, 0, nil, e)
return
}
m.Password = saved.Password
}
c, e := signals.Connect(r.Context(), m)
if e != nil {
respond(w, 0, nil, bad("Mailbox connection or login failed"))
return
}
c.Logout()
respond(w, 200, OK{true}, nil)
}
func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
// Bounded, single-connection pass: the watched-bookmark query is capped and
// all searches reuse one IMAP login under an overall deadline (matches the
// scheduler's 45s budget) instead of one fresh connection per bookmark.
bms, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "imapWatchEnabled" ORDER BY "createdAt" LIMIT 50`, user(r))
if e != nil {
respond(w, 0, nil, e)
return
}
matches := []object{}
watched := make([]object, 0, len(bms))
for _, b := range bms {
if str(b, "imapQuery") != "" {
watched = append(watched, b)
}
}
if len(watched) > 0 {
m, e := signals.LoadMailbox(r.Context(), database(r), user(r))
if e != nil {
respond(w, 0, nil, bad("Configure IMAP first"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
c, e := signals.Connect(ctx, m)
if e != nil {
respond(w, 0, nil, bad("Mailbox connection or login failed"))
return
}
stop := context.AfterFunc(ctx, func() { c.Terminate() })
defer stop()
defer c.Logout()
searchFailed := false
for _, b := range watched {
events, e := signals.SearchConn(c, m, "", str(b, "imapQuery"))
// A search can fail after returning partial events (review
// follow-up). Surface those matches and the failure together;
// only a failure with nothing found stays a plain 400.
for _, event := range events {
matches = append(matches, object{"bookmarkId": b["id"], "title": event.Title, "from": event.Summary, "externalId": event.ExternalID, "occurredAt": event.OccurredAt})
}
if e != nil {
searchFailed = true
break
}
}
if searchFailed {
if len(matches) == 0 {
respond(w, 0, nil, bad("Mailbox search failed"))
return
}
respond(w, 200, object{"matches": matches, "error": "Mailbox search failed (partial results)"}, nil)
return
}
}
respond(w, 200, object{"matches": matches}, nil)
}
+120
View File
@@ -0,0 +1,120 @@
package handlers
import (
"net/url"
"testing"
)
func TestMailboxRejectsUnusualPort(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "")
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "")
if _, e := mailboxData(object{"host": "mail.example.com", "username": "u", "password": "p", "port": float64(22), "tls": true}); e == nil {
t.Fatal("mailbox accepted a non-IMAP port")
}
if _, e := mailboxData(object{"host": "mail.example.com", "username": "u", "password": "p", "port": float64(993), "tls": true}); e != nil {
t.Fatalf("normal IMAP port rejected: %v", e)
}
}
// TestResolveCardDAVTarget pins review CR-01: the COMPLETE resolved
// destination is revalidated, so a same-host absolute http:// path cannot
// downgrade an https base and carry Basic credentials over plaintext.
func TestResolveCardDAVTarget(t *testing.T) {
base, e := url.Parse("https://cloud.example.com/remote.php/dav")
if e != nil {
t.Fatal(e)
}
accepted := []string{
"/remote.php/dav/addressbooks/users/alice/contacts/",
"addressbooks/users/alice/contacts/",
"https://cloud.example.com/remote.php/dav/addressbooks/users/alice/contacts/",
}
for _, path := range accepted {
target, e := resolveCardDAVTarget(base, path)
if e != nil {
t.Fatalf("resolveCardDAVTarget(%q) rejected: %v", path, e)
}
if target.Scheme != "https" || target.Host != "cloud.example.com" {
t.Fatalf("resolveCardDAVTarget(%q) resolved to %s", path, target)
}
}
rejected := []string{
// Same host, downgraded scheme — the CR-01 credential-leak vector.
"http://cloud.example.com/remote.php/dav/addressbooks/users/alice/contacts/",
// Cross-host absolute URLs.
"https://evil.example.com/remote.php/dav/",
"http://evil.example.com/remote.php/dav/",
// Scheme-relative and userinfo tricks.
"//evil.example.com/dav/",
"https://alice:pw@cloud.example.com/dav/",
}
for _, path := range rejected {
if target, e := resolveCardDAVTarget(base, path); e == nil {
t.Fatalf("resolveCardDAVTarget(%q) accepted, resolved to %s", path, target)
}
}
}
// TestParseVCardSubset pins the documented parser subset (review CR-08):
// repeated properties are preserved, PREF selection is deterministic,
// groups/params/escapes/folding are handled, and base64-encoded properties
// are dropped instead of silently misparsed.
func TestParseVCardSubset(t *testing.T) {
card := "BEGIN:VCARD\r\n" +
"VERSION:3.0\r\n" +
"UID:uid-1\r\n" +
"item1.FN:Preferred Name\r\n" +
"item1.X-ABLabel:Main\r\n" +
"EMAIL;TYPE=WORK:work@computer.test\r\n" +
"EMAIL;TYPE=PREF:pref@computer.test\r\n" +
"EMAIL:plain@computer.test\r\n" +
"TEL;TYPE=HOME,VOICE:+1 555 0100\r\n" +
"NOTE:folded line\r\n" +
" continuation\r\n" +
"X-ESCAPED:a\\,b\\;c\\\\d\\ne\r\n" +
"PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRg==\r\n" +
"END:VCARD"
v := parseVCard(card)
if got := vCardPick(v["UID"]); got != "uid-1" {
t.Fatalf("UID = %q", got)
}
// Group prefix stripped (item1.FN -> FN).
if got := vCardPick(v["FN"]); got != "Preferred Name" {
t.Fatalf("FN = %q", got)
}
// All three EMAIL occurrences survive; TYPE=PREF wins deterministically.
if len(v["EMAIL"]) != 3 {
t.Fatalf("repeated EMAILs not preserved: %d", len(v["EMAIL"]))
}
if got := vCardPick(v["EMAIL"]); got != "pref@computer.test" {
t.Fatalf("PREF selection = %q", got)
}
// vCard 4.0 numeric PREF ranks below any TYPE=PREF-free occurrence.
num := parseVCard("EMAIL:a@x.test\r\nEMAIL;PREF=2:b@x.test\r\nEMAIL;PREF=1:c@x.test\r\n")
if got := vCardPick(num["EMAIL"]); got != "c@x.test" {
t.Fatalf("numeric PREF selection = %q", got)
}
// Multi-value parameters (TYPE=HOME,VOICE) are split.
tel := v["TEL"]
if len(tel) != 1 || tel[0].params["TYPE"][0] != "HOME" || tel[0].params["TYPE"][1] != "VOICE" {
t.Fatalf("TEL params = %+v", tel)
}
// CRLF-space folding unfolds into one value: the first space is the fold
// marker, the second is content.
if got := vCardPick(v["NOTE"]); got != "folded line continuation" {
t.Fatalf("NOTE unfolding = %q", got)
}
// Escapes decode.
if got := vCardPick(v["X-ESCAPED"]); got != "a,b;c\\d\ne" {
t.Fatalf("escapes = %q", got)
}
// Base64-encoded properties are dropped, not stored as garbage.
if _, ok := v["PHOTO"]; ok {
t.Fatal("base64-encoded PHOTO was parsed instead of dropped")
}
// Missing properties pick to "".
if got := vCardPick(v["ORG"]); got != "" {
t.Fatalf("missing property = %q", got)
}
}
+203
View File
@@ -0,0 +1,203 @@
package handlers
import (
"context"
"database/sql"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/markdown"
"net/http"
"strings"
"time"
)
// pageView is the canonical page projection over any queryer. HandlePagesUpdate
// reads it INSIDE the write transaction (review CR-18): a post-commit query
// could observe another writer's committed revision, and the acknowledgement
// would then describe state this write did not produce — a client adopting it
// could overwrite the other writer on its next save.
func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) {
// Linked bookmarks use the viewer projection (review PRIV-02): a page can
// link bookmarks shared with its owner, and those arrive narrowed.
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object('bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb)) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
}
func page(r *http.Request, id string) (object, error) {
return pageView(r.Context(), database(r), id, user(r))
}
func HandlePagesList(w http.ResponseWriter, r *http.Request) {
// Review PERF-01: the list is a metadata projection — content bodies stay
// out of it (the sidebar/tab strip only render titles) and are fetched on
// demand via GET /api/pages/{id}. Search still matches content server-side.
// Escape LIKE wildcards (review PERF-2): "%" and "_" in a search are
// literal characters, not patterns. The pg_trgm indexes on title/content
// let Postgres use the same ILIKE predicate without scanning every page.
search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%"
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
respond(w, 200, v, e)
}
func pageData(b object) (object, error) {
d := fields(b, "title content icon pinned")
if e := stringFields(d, "icon", "title content"); e != nil {
return nil, e
}
if e := boolFields(d, "pinned"); e != nil {
return nil, e
}
if e := limitText(d, "content", maxPageContentLen); e != nil {
return nil, e
}
if e := limitText(d, "icon", maxPageIconLen); e != nil {
return nil, e
}
if _, ok := d["title"]; ok {
s := strings.TrimSpace(str(d, "title"))
if s == "" {
s = "Untitled page"
}
rs := []rune(s)
if len(rs) > 120 {
rs = rs[:120]
}
d["title"] = string(rs)
}
return d, nil
}
func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := pageData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if _, ok := d["title"]; !ok {
d["title"] = "Untitled page"
}
d["id"], d["ownerId"] = db.NewID(), user(r)
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
var n int
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil {
return e
}
d["position"] = n
var e error
v, e = db.Insert(r.Context(), tx, "Page", d)
return e
})
respond(w, 201, v, e)
}
func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
list, e := ids(b, "order")
if e == nil {
e = reorder(r, "Page", "ownerId", list)
}
respond(w, 200, OK{true}, e)
}
func HandlePagesGet(w http.ResponseWriter, r *http.Request) {
v, e := page(r, r.PathValue("id"))
respond(w, 200, v, e)
}
func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
b, ok := read(w, r)
if !ok {
return
}
d, e := pageData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
// Optimistic concurrency (review DATA-01): a client that sends the
// version it edited from gets a 409 instead of silently overwriting a
// newer save from another tab or device. The revision counter tracks
// content edits — metadata patches (title/pin/links) neither require nor
// bump it, so they cannot invalidate an editor's in-flight draft.
baseVersion, hasVersion := 0, false
if v, ok := b["version"]; ok {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
// Review DATA-05: concurrency must not be optional at the API boundary.
// A content write without a base version is rejected outright — otherwise
// any script or stale client could silently clobber newer content by
// omitting the field. Explicit overwrites re-fetch the latest version and
// send it (web `resolveOverwrite`); they never bypass this check.
if _, hasContent := d["content"]; hasContent && !hasVersion {
respond(w, 0, nil, bad("version is required when saving content; refetch the page and retry"))
return
}
d["updatedAt"] = time.Now().UTC()
// Review CR-18: the response snapshot is captured inside the same
// transaction as the write, so the acknowledged revision always
// describes exactly the state this request produced — never a later
// commit by a competing writer.
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// Same locking discipline as page creation/reorder: serializes
// max(position)+1 so concurrent link adds cannot duplicate positions.
if e := lockUser(r, tx); e != nil {
return e
}
current, e := owned(r, tx, "Page", id, "ownerId")
if e != nil {
return e
}
if hasVersion {
stored, _ := current["version"].(float64)
if int(stored) != baseVersion {
return conflict("Page was modified elsewhere; reload to see the latest version")
}
}
if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil {
return e
}
if _, ok := d["content"]; ok {
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil {
return e
}
}
if bid := str(b, "addBookmarkId"); bid != "" {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
return bad("Bookmark is not accessible")
}
_, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) SELECT $1,$2,COALESCE(max(position),-1)+1 FROM "PageBookmark" WHERE "pageId"=$1 ON CONFLICT DO NOTHING`, id, bid)
if e != nil {
return e
}
}
if bid := str(b, "removeBookmarkId"); bid != "" {
_, e := tx.ExecContext(r.Context(), `DELETE FROM "PageBookmark" WHERE "pageId"=$1 AND "bookmarkId"=$2`, id, bid)
if e != nil {
return e
}
}
v, e = pageView(r.Context(), tx, id, user(r))
return e
})
respond(w, 200, v, e)
}
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
}
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
v, e := page(r, r.PathValue("id"))
if e != nil {
respond(w, 0, nil, e)
return
}
markdown.Send(w, markdown.PageDocument(v), str(v, "title"))
}
+15
View File
@@ -0,0 +1,15 @@
package handlers
import "net/http"
// HandlePublicBookmark serves the unauthenticated share page. The projection
// in bookmark(..., public=true) is an allowlist; private metadata (contact,
// owner identity, imap settings, alerts, kanban placement, shares, page
// links) is never loaded — review PRIV-04 removed the owner-name attribution
// so the API matches the README's promise that owner identity stays private.
// Notes and dueDate ARE published by product decision — the share UI states
// exactly what a public link exposes.
func HandlePublicBookmark(w http.ResponseWriter, r *http.Request) {
v, e := bookmark(r, r.PathValue("id"), true)
respond(w, 200, v, e)
}
+118
View File
@@ -0,0 +1,118 @@
package handlers
import (
"context"
"encoding/json"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/signals"
"net/http"
"strings"
"time"
)
func HandleSignalsList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := bookmark(r, id, false); e != nil {
respond(w, 0, nil, e)
return
}
sources, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "SignalSource" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "createdAt"`, id, user(r))
if e != nil {
respond(w, 0, nil, e)
return
}
events, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s) FROM "Signal" s WHERE "bookmarkId"=$1 AND "ownerId"=$2 ORDER BY "occurredAt" DESC LIMIT 50`, id, user(r))
respond(w, 200, object{"sources": sources, "signals": events, "types": signals.PublicSourceTypes()}, e)
}
func HandleSignalsCreate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Bookmark", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
if str(b, "action") == "check" {
// Review COR-4: a manual "Check now" must not race with the scheduler
// or another click. Take the SAME advisory lease the scheduler uses —
// identical key, held on a dedicated connection because session-level
// advisory locks are connection-bound (a pooled lock/unlock could land
// on different connections and leak the lock) — and answer 409 while
// it is held. The 45-second cap keeps the pass inside the server's
// WriteTimeout so it cannot be cut off mid-mutation.
ctx := r.Context()
leaseKey := "signal-poll:" + user(r)
conn, e := database(r).Conn(ctx)
if e != nil {
respond(w, 0, nil, e)
return
}
defer conn.Close()
var locked bool
if e := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, leaseKey).Scan(&locked); e != nil {
respond(w, 0, nil, e)
return
}
if !locked {
respond(w, 0, nil, conflict("A check is already running"))
return
}
defer conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, leaseKey)
job, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
res, e := signals.RunSignalChecks(job, database(r), user(r), id)
if e != nil {
respond(w, 0, nil, e)
return
}
// Review CR-16: a manual check answers 200 with the structured
// per-source outcome (detail lives in each source's lastStatus);
// failed sources are counted, not folded into a blanket 500.
respond(w, 200, object{"checked": res.Sources, "created": res.Created, "failed": res.Failed}, nil)
return
}
if str(b, "type") != "email" {
respond(w, 0, nil, bad("Unknown signal type"))
return
}
cfg, ok := b["config"].(map[string]any)
if !ok {
respond(w, 0, nil, bad("Config must be an object"))
return
}
clean := object{}
for _, key := range []string{"from", "subject"} {
if v, ok := cfg[key]; ok {
s, ok := v.(string)
if !ok {
respond(w, 0, nil, bad("Config values must be text"))
return
}
rs := []rune(strings.TrimSpace(s))
if len(rs) > 200 {
rs = rs[:200]
}
clean[key] = string(rs)
}
}
raw, _ := json.Marshal(clean)
v, e := db.Insert(r.Context(), database(r), "SignalSource", object{"id": db.NewID(), "bookmarkId": id, "ownerId": user(r), "type": "email", "config": string(raw)})
respond(w, 201, v, e)
}
func HandleSignalSourcesUpdate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
if _, ok = b["enabled"].(bool); !ok {
respond(w, 0, nil, bad("Enabled must be boolean"))
return
}
v, e := db.Update(r.Context(), database(r), "SignalSource", r.PathValue("id"), "ownerId", user(r), object{"enabled": b["enabled"], "updatedAt": time.Now().UTC()})
respond(w, 200, v, e)
}
func HandleSignalSourcesDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "SignalSource", r.PathValue("id"), "ownerId"))
}
+96
View File
@@ -0,0 +1,96 @@
package handlers
import (
"database/sql"
"github.com/andrew/go-computer/internal/db"
"net/http"
"strings"
"time"
)
// Sharing metadata (share list, total usage) belongs to the owner alone;
// recipients see the tag plus only their own usage count.
func HandleTagsList(w http.ResponseWriter, r *http.Request) {
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(t)||jsonb_build_object(
'_count',jsonb_build_object('bookmarks',
CASE WHEN t."ownerId"=$1
THEN (SELECT count(*) FROM "BookmarkTag" bt WHERE bt."tagId"=t.id)
ELSE (SELECT count(*) FROM "BookmarkTag" bt JOIN "Bookmark" b ON b.id=bt."bookmarkId" WHERE bt."tagId"=t.id AND b."ownerId"=$1)
END),
'sharedWith',
CASE WHEN t."ownerId"=$1
THEN COALESCE((SELECT jsonb_agg(to_jsonb(s)||jsonb_build_object('user',jsonb_build_object('id',u.id,'name',u.name,'email',u.email))) FROM "TagShare" s JOIN "User" u ON u.id=s."userId" WHERE s."tagId"=t.id),'[]'::jsonb)
ELSE '[]'::jsonb
END)
FROM "Tag" t WHERE "ownerId"=$1 OR EXISTS(SELECT 1 FROM "TagShare" s WHERE s."tagId"=t.id AND s."userId"=$1) ORDER BY name`, user(r))
respond(w, 200, v, e)
}
func tagData(b object) (object, error) {
d := fields(b, "name color")
if e := stringFields(d, "", "name color"); e != nil {
return nil, e
}
if e := limitText(d, "name", maxTagNameLen); e != nil {
return nil, e
}
if _, ok := d["name"]; ok {
d["name"] = strings.TrimSpace(str(d, "name"))
if d["name"] == "" {
return nil, bad("Name required")
}
}
return d, validColor(d)
}
func HandleTagsCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := tagData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "name") == "" {
respond(w, 0, nil, bad("Name required"))
return
}
d["id"], d["ownerId"] = db.NewID(), user(r)
v, e := db.Insert(r.Context(), database(r), "Tag", d)
respond(w, 201, v, e)
}
func HandleTagsUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Tag", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
b, ok := read(w, r)
if !ok {
return
}
d, e := tagData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
list, e := ids(b, "shareUserIds")
if e != nil {
respond(w, 0, nil, e)
return
}
d["updatedAt"] = time.Now().UTC()
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
var e error
v, e = db.Update(r.Context(), tx, "Tag", id, "ownerId", user(r), d)
if e != nil {
return e
}
return shareRows(r, tx, "TagShare", "tagId", id, list)
})
respond(w, 200, v, e)
}
func HandleTagsDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "Tag", r.PathValue("id"), "ownerId"))
}
+25
View File
@@ -0,0 +1,25 @@
package handlers
import (
"github.com/andrew/go-computer/internal/db"
"net/http"
)
func unshare(w http.ResponseWriter, r *http.Request, parent, join, key string) {
id := r.PathValue("id")
if _, e := owned(r, database(r), parent, id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
_, e := database(r).ExecContext(r.Context(), `DELETE FROM `+db.Quote(join)+` WHERE `+db.Quote(key)+`=$1 AND "userId"=$2`, id, r.PathValue("userId"))
respond(w, 200, OK{true}, e)
}
func HandleBookmarkUnshare(w http.ResponseWriter, r *http.Request) {
unshare(w, r, "Bookmark", "BookmarkShare", "bookmarkId")
}
func HandleTagUnshare(w http.ResponseWriter, r *http.Request) {
unshare(w, r, "Tag", "TagShare", "tagId")
}
func HandleSharedDateDelete(w http.ResponseWriter, r *http.Request) {
respond(w, 200, OK{true}, remove(r, "SharedDate", r.PathValue("id"), "creatorId"))
}
+420
View File
@@ -0,0 +1,420 @@
package http
import (
"testing"
)
// Collections (Notion-like upgrades phase 2). Proves the locked semantics:
// contextual membership (same bookmark, independent workflows, bookmark
// never owned by the membership), versioned PATCHes (DATA-05 lesson),
// server-side validated filters/sorts with deterministic cursor pagination,
// and access revocation reaching every item surface (N-08).
func collectionBookmark(t *testing.T, c apiClient, url string) string {
t.Helper()
return c.request("POST", "/api/bookmarks", map[string]any{"url": url, "title": "BM " + url}, 201)["id"].(string)
}
func TestCollectionsLifecycle(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-life@computer.test")
bob := newAPIUser(t, srv, "coll-other@computer.test")
bm1 := collectionBookmark(t, alice, "https://127.0.0.1/one")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Research", "description": "phase 2"}, 201)
cid := col["id"].(string)
views, _ := col["views"].([]any)
if len(views) != 1 {
t.Fatalf("new collection did not get a default view: %v", col)
}
// Property surface: typed, validated, RELATION reserved but rejected.
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Rel", "type": "RELATION"}, 400)
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Bad", "type": "PERSON"}, 400)
status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{
map[string]any{"label": "Backlog"}, map[string]any{"label": "Doing"}, map[string]any{"label": "Done"},
}},
}, 201)
statusID := status["id"].(string)
opts, _ := status["config"].(map[string]any)["options"].([]any)
doing := opts[1].(map[string]any)["id"].(string)
done := opts[2].(map[string]any)["id"].(string)
priority := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "NUMBER"}, 201)
priorityID := priority["id"].(string)
notes := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Notes", "type": "TEXT"}, 201)
notesID := notes["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Due", "type": "DATE", "config": map[string]any{"mode": "bogus"},
}, 400)
due := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Due", "type": "DATE", "config": map[string]any{"mode": "date"},
}, 201)
dueID := due["id"].(string)
// Duplicate property name is a unique violation -> 409.
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "TEXT"}, 409)
// Items: visibility required at creation, values validated, duplicates 409.
foreign := collectionBookmark(t, bob, "https://127.0.0.1/private")
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": foreign}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{statusID: "not-an-option"}}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{notesID: 5}}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{dueID: "2026-13-45"}}, 400)
item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{statusID: doing, priorityID: 2, dueID: "2026-12-31", notesID: "context"}}, 201)
itemID := item["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 409)
pv, _ := item["propertyValues"].(map[string]any)
if pv[dueID] != "2026-12-31" {
t.Fatalf("date-only value not stored verbatim: %v", pv[dueID])
}
if _, ok := item["bookmark"]; !ok {
t.Fatal("item response missing the bookmark projection")
}
// Item PATCH enforces version (DATA-05 from day one) and merge semantics:
// omitted keys untouched, null removes.
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"propertyValues": map[string]any{notesID: "x"}}, 400)
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 99, "propertyValues": map[string]any{notesID: "x"}}, 409)
updated := alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{
"version": 1, "propertyValues": map[string]any{notesID: nil, statusID: done},
}, 200)
if updated["version"].(float64) != 2 {
t.Fatalf("item patch did not bump version: %v", updated["version"])
}
pv, _ = updated["propertyValues"].(map[string]any)
if _, stillThere := pv[notesID]; stillThere {
t.Fatal("null did not remove the value")
}
if pv[statusID] != done {
t.Fatal("status not updated")
}
// The same bookmark in a second collection: contextual values independent,
// intrinsic edits propagate to both (test matrix row 1).
col2 := alice.request("POST", "/api/collections", map[string]any{"name": "Reading"}, 201)
cid2 := col2["id"].(string)
status2 := alice.request("POST", "/api/collections/"+cid2+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{map[string]any{"label": "Unread"}, map[string]any{"label": "Read"}}},
}, 201)
unread := status2["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string)
item2 := alice.request("POST", "/api/collections/"+cid2+"/items", map[string]any{
"bookmarkId": bm1, "propertyValues": map[string]any{status2["id"].(string): unread},
}, 201)
putBookmark(alice, bm1, map[string]any{"title": "Renamed intrinsically"}, 200)
for _, pair := range [][2]string{{cid, itemID}, {cid2, item2["id"].(string)}} {
got := alice.getJSON("/api/collections/"+pair[0]+"/items", 200)
found := false
for _, it := range got {
if it["id"] == pair[1] {
found = true
bmk := it["bookmark"].(map[string]any)
if bmk["title"] != "Renamed intrinsically" {
t.Fatal("intrinsic edit did not propagate to membership", pair[0])
}
}
}
if !found {
t.Fatal("item missing from list", pair[0])
}
}
// Contextual independence: collection 1 says Done, collection 2 says Unread.
first := alice.getJSON("/api/collections/"+cid+"/items", 200)[0]
if first["propertyValues"].(map[string]any)[statusID] != done {
t.Fatal("contextual value leaked between collections")
}
// Removing membership and then the whole collection never touches the
// bookmark (N-01 acceptance).
alice.request("DELETE", "/api/collections/"+cid2+"/items/"+item2["id"].(string), nil, 200)
alice.request("GET", "/api/bookmarks/"+bm1, nil, 200)
alice.request("DELETE", "/api/collections/"+cid2, nil, 200)
alice.request("GET", "/api/bookmarks/"+bm1, nil, 200)
alice.request("GET", "/api/collections/"+cid2, nil, 404)
// Foreign users get 404 on every collection surface (ownership flow).
bob.request("GET", "/api/collections/"+cid, nil, 404)
bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404)
bob.request("GET", "/api/collections/"+cid+"/items", nil, 404)
bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 404)
bob.request("DELETE", "/api/collections/"+cid, nil, 404)
}
func TestCollectionViewsFilterSortPaginate(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-views@computer.test")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Pipeline"}, 201)
cid := col["id"].(string)
status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{map[string]any{"label": "Doing"}, map[string]any{"label": "Done"}}},
}, 201)
statusID := status["id"].(string)
doing := status["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string)
done := status["config"].(map[string]any)["options"].([]any)[1].(map[string]any)["id"].(string)
prio := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Prio", "type": "NUMBER"}, 201)
prioID := prio["id"].(string)
ids := []string{}
for i, s := range []string{doing, done, doing} {
bm := collectionBookmark(t, alice, "https://127.0.0.1/v"+string(rune('a'+i)))
p := float64(i)
if i == 2 {
p = 1 // tie with item 1 on the sort key: id must break it deterministically
}
it := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{
"bookmarkId": bm, "propertyValues": map[string]any{statusID: s, prioID: p},
}, 201)
ids = append(ids, it["id"].(string))
}
// View validation: unknown properties and type-incompatible operators 400.
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad", "filters": map[string]any{
"operator": "AND", "clauses": []any{map[string]any{"propertyId": "nope", "op": "eq", "value": "x"}},
}}, 400)
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad2", "filters": map[string]any{
"clauses": []any{map[string]any{"propertyId": statusID, "op": "gt", "value": doing}},
}}, 400)
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad3", "sorts": []any{
map[string]any{"field": "nope"},
}}, 400)
board := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{
"name": "Doing board", "type": "BOARD",
"filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": statusID, "op": "eq", "value": doing}}},
"sorts": []any{map[string]any{"field": prioID, "dir": "desc"}},
"groupByPropertyId": statusID,
}, 201)
vid := board["id"].(string)
// Server-side filtering: only the two Doing items come back.
doingItems := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid, 200)
if len(doingItems) != 2 {
t.Fatalf("filter returned %d items, want 2", len(doingItems))
}
// Sorted by priority desc: first page item has prio 1 (the tie item), and
// cursor pagination with limit=1 walks both pages exactly once.
first := doingItems[0]
if first["propertyValues"].(map[string]any)[prioID].(float64) != 1 {
t.Fatal("sort desc did not put the higher priority first")
}
res, e := alice.c.Get(alice.base + "/api/collections/" + cid + "/items?viewId=" + vid + "&limit=1")
if e != nil {
t.Fatal(e)
}
cursor := res.Header.Get("X-Next-Cursor")
res.Body.Close()
if cursor == "" {
t.Fatal("no cursor despite more rows")
}
page := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid+"&limit=1&cursor="+cursor, 200)
if len(page) != 1 {
t.Fatalf("cursor page returned %d items", len(page))
}
if page[0]["id"] == first["id"] {
t.Fatal("cursor page repeated the first row (non-deterministic tie-break)")
}
// View PATCH enforces version like everything else.
alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"name": "No version"}, 400)
alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 99, "name": "Stale"}, 409)
renamed := alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 1, "name": "Doing board v2"}, 200)
if renamed["version"].(float64) != 2 {
t.Fatalf("view patch did not bump version: %v", renamed["version"])
}
// Option removal with existing values is refused (stated policy); after
// clearing the values it succeeds. Renames keep the option identity.
alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1,
"config": map[string]any{"options": []any{map[string]any{"id": doing, "label": "In flight"}}}}, 400)
// Clear the Doing values by moving every item to Done.
for _, row := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
alice.request("PATCH", "/api/collections/"+cid+"/items/"+row["id"].(string), map[string]any{
"version": row["version"].(float64), "propertyValues": map[string]any{statusID: done},
}, 200)
}
renamedProp := alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1,
"config": map[string]any{"options": []any{map[string]any{"id": done, "label": "Finished"}}}}, 200)
opts := renamedProp["config"].(map[string]any)["options"].([]any)
if len(opts) != 1 || opts[0].(map[string]any)["id"] != done {
t.Fatalf("option removal/rename produced %+v", opts)
}
// Type changes are forbidden; deleting the property scrubs values and views.
alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 2, "type": "TEXT"}, 400)
alice.request("DELETE", "/api/collections/"+cid+"/properties/"+statusID, nil, 200)
view := alice.request("GET", "/api/collections/"+cid, nil, 200)
for _, raw := range view["views"].([]any) {
v := raw.(map[string]any)
if v["id"] != vid {
continue
}
f := v["filters"].(map[string]any)
if len(f["clauses"].([]any)) != 0 {
t.Fatalf("deleted property left filter clauses behind: %v", f)
}
// The sort keys on the surviving Prio property and must be untouched.
if len(v["sorts"].([]any)) != 1 {
t.Fatalf("unrelated sorts were scrubbed: %v", v["sorts"])
}
if v["groupByPropertyId"] != nil {
t.Fatalf("groupBy was not cleared: %v", v["groupByPropertyId"])
}
}
for _, raw := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
pv, _ := raw["propertyValues"].(map[string]any)
if _, still := pv[statusID]; still {
t.Fatal("deleted property left item values behind")
}
}
}
func TestCollectionRevocationReachesItems(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-rev@computer.test")
aliceID := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
bob := newAPIUser(t, srv, "coll-rev-owner@computer.test")
// Bob owns a bookmark and shares it with Alice; Alice puts it in HER
// collection. Revocation must make the item vanish from every surface
// (list, count, mutation) without deleting anything (N-08, decision #4).
shared := collectionBookmark(t, bob, "https://127.0.0.1/shared")
bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Watchlist"}, 201)
cid := col["id"].(string)
item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": shared}, 201)
itemID := item["id"].(string)
if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 1 {
t.Fatal("shared bookmark item not listed")
}
for _, c := range alice.getJSON("/api/collections", 200) {
if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 1 {
t.Fatalf("authorized count wrong: %v", c["_count"])
}
}
bob.request("DELETE", "/api/bookmarks/"+shared+"/share/"+aliceID, nil, 200)
if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 0 {
t.Fatal("revoked bookmark still listed in collection items")
}
for _, c := range alice.getJSON("/api/collections", 200) {
if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 0 {
t.Fatalf("revoked bookmark still counted: %v", c["_count"])
}
}
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 1, "position": 3}, 404)
alice.request("DELETE", "/api/collections/"+cid+"/items/"+itemID, nil, 404)
// Review COR-2: a hidden membership row must not wedge reorder. Alice
// adds one of her own bookmarks, then reorders the visible items — the
// revoked membership stays hidden at the tail instead of making every
// PUT /items fail "Order must contain every item exactly once".
own := collectionBookmark(t, alice, "https://127.0.0.1/alice-own")
ownItem := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": own}, 201)
ownID := ownItem["id"].(string)
visible := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(visible) != 1 || visible[0]["id"] != ownID {
t.Fatalf("expected only the own item visible, got %v", visible)
}
alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{ownID}}, 200)
// The membership row itself survives revocation: re-sharing restores it.
bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200)
items := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(items) != 2 {
t.Fatal("re-sharing did not restore the existing membership")
}
}
// Review CR-09: indirect mutations advance revisions — reorder bumps every
// affected item, property deletion bumps the items whose values it dropped
// and only the views whose definitions it actually changed. Stale PATCHes
// based on the pre-reorder / pre-deletion state get 409s.
func TestCollectionIndirectMutationsAdvanceVersions(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-rev@computer.test")
bm1 := collectionBookmark(t, alice, "https://127.0.0.1/r1")
bm2 := collectionBookmark(t, alice, "https://127.0.0.1/r2")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Revisions"}, 201)
cid := col["id"].(string)
dvid := col["views"].([]any)[0].(map[string]any)["id"].(string)
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Label", "type": "TEXT"}, 201)
pid := prop["id"].(string)
it1 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, "propertyValues": map[string]any{pid: "one"}}, 201)
it2 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm2}, 201)
id1, id2 := it1["id"].(string), it2["id"].(string)
filtered := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{
"name": "Filtered", "type": "TABLE",
"filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": pid, "op": "eq", "value": "one"}}},
}, 201)
fvid := filtered["id"].(string)
itemVersion := func(id string) int {
t.Helper()
for _, it := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
if it["id"] == id {
v, _ := it["version"].(float64)
return int(v)
}
}
t.Fatalf("item %s missing from list", id)
return -1
}
viewVersion := func(id string) int {
t.Helper()
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
for _, raw := range detail["views"].([]any) {
v := raw.(map[string]any)
if v["id"] == id {
n, _ := v["version"].(float64)
return int(n)
}
}
t.Fatalf("view %s missing", id)
return -1
}
// Baselines: collection entities start at version 1 (migration 002).
base1, base2 := itemVersion(id1), itemVersion(id2)
fv0, dv0 := viewVersion(fvid), viewVersion(dvid)
// Reorder advances every affected item's revision...
alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{id2, id1}}, 200)
if v := itemVersion(id1); v != base1+1 {
t.Fatalf("reorder left item1 at version %d, want %d", v, base1+1)
}
if v := itemVersion(id2); v != base2+1 {
t.Fatalf("reorder left item2 at version %d, want %d", v, base2+1)
}
// ...so a PATCH based on the pre-reorder snapshot is a 409, and the
// current base still succeeds.
alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1}, 409)
alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1 + 1}, 200)
if v := itemVersion(id1); v != base1+2 {
t.Fatalf("PATCH did not advance item1 to version %d: %d", base1+2, v)
}
// Property deletion: the item carrying a value advances; the item without
// one does not. The view filtering on the property advances (and its
// clause is scrubbed); the untouched default view keeps its revision.
alice.request("DELETE", "/api/collections/"+cid+"/properties/"+pid, nil, 200)
if v := itemVersion(id1); v != base1+3 {
t.Fatalf("property deletion left valued item1 at version %d, want %d", v, base1+3)
}
if v := itemVersion(id2); v != base2+1 {
t.Fatalf("property deletion bumped valueless item2 to %d, want %d", v, base2+1)
}
if v := viewVersion(fvid); v != fv0+1 {
t.Fatalf("property deletion left the filtered view at version %d, want %d", v, fv0+1)
}
if v := viewVersion(dvid); v != dv0 {
t.Fatalf("property deletion bumped the untouched default view to %d, want %d", v, dv0)
}
}
+510
View File
@@ -0,0 +1,510 @@
package http
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/signals"
"github.com/emersion/go-imap/backend/memory"
imapserver "github.com/emersion/go-imap/server"
"github.com/golang-jwt/jwt/v5"
)
// Covers routes that previously had no coverage at any level (CODE_REVIEW
// TEST-1) plus signout revocation (AUTH-5) and the background scheduler
// (TEST-2).
func (a apiClient) getJSON(path string, status int) []map[string]any {
a.t.Helper()
res, e := a.c.Get(a.base + path)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw)
}
var v []map[string]any
if e := json.Unmarshal(raw, &v); e != nil {
a.t.Fatalf("GET %s: not a JSON array: %v: %s", path, e, raw)
}
return v
}
func (a apiClient) getText(path string, status int) string {
a.t.Helper()
res, e := a.c.Get(a.base + path)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw)
}
return string(raw)
}
func newAPIUser(t *testing.T, srv *httptest.Server, email string) apiClient {
t.Helper()
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, srv.URL}
creds := map[string]any{"email": email, "password": "coverage-test-password"}
c.request("POST", "/api/signup", creds, 201)
c.request("POST", "/api/auth/login", creds, 200)
return c
}
func TestPreviouslyUncoveredRoutes(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coverage-alice@computer.test")
newAPIUser(t, srv, "coverage-searchable@computer.test")
// GET /api/preview — handler plumbing (auth + query + parse).
html := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, `<html><head><title>Preview Target</title><meta name="description" content="Described"></head><body>x</body></html>`)
}))
defer html.Close()
anon, _ := cookiejar.New(nil)
anonymous := apiClient{t, &http.Client{Jar: anon}, srv.URL}
anonymous.request("GET", "/api/preview?url="+html.URL, nil, 401)
if v := alice.request("GET", "/api/preview?url="+html.URL, nil, 200); v["title"] != "Preview Target" || v["description"] != "Described" {
t.Fatal("preview handler:", v)
}
// GET /api/bookmarks/{id}/markdown — content, not just the link element.
b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/coverage", "title": "Coverage Bookmark", "notes": "# Coverage notes"}, 201)
id := b["id"].(string)
md := alice.getText("/api/bookmarks/"+id+"/markdown", 200)
if !strings.Contains(md, "https://example.org/coverage") || !strings.Contains(md, "Coverage notes") {
t.Fatal("bookmark markdown:", md)
}
// GET /api/users/search — name substring, exact email, masked output,
// no email-substring enumeration, 2-rune guard.
users := alice.getJSON("/api/users/search?q=coverage-sea", 200)
if len(users) != 1 || users[0]["email"] != "c***@computer.test" {
t.Fatal("users/search name match:", users)
}
if users := alice.getJSON("/api/users/search?q=coverage-searchable@computer.test", 200); len(users) != 1 {
t.Fatal("users/search exact email:", users)
}
// "able@computer.test" is a substring of the target's email but not a
// full email and not part of any name: must not match.
if users := alice.getJSON("/api/users/search?q=able%40computer.test", 200); len(users) != 0 {
t.Fatal("users/search must not substring-match emails:", users)
}
if users := alice.getJSON("/api/users/search?q=c", 200); len(users) != 0 {
t.Fatal("users/search short-query guard:", users)
}
// Review SEC-3: LIKE wildcards are escaped, so "%%"/"_%" cannot enumerate
// the whole user base. "%%" matches a literal percent-pair (2 runes, so
// it passes the minimum-length guard); a name literally containing "%%"
// still matches, but nothing else does.
if users := alice.getJSON("/api/users/search?q=%25%25", 200); len(users) != 0 {
t.Fatalf("users/search wildcard enumeration: %v", users)
}
// PATCH /api/kanban/columns/{id} and PUT /api/kanban/columns happy paths.
col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Coverage"}, 201)["id"].(string)
alice.request("PATCH", "/api/kanban/columns/"+col, map[string]any{"label": "Renamed", "color": "#00ff00"}, 200)
cols := alice.getJSON("/api/kanban/columns", 200)
var found bool
order := make([]string, 0, len(cols))
for _, c := range cols {
order = append(order, c["id"].(string))
if c["id"] == col {
found = c["label"] == "Renamed" && c["color"] == "#00ff00"
}
}
if !found {
t.Fatal("column patch not applied:", cols)
}
reversed := make([]string, 0, len(order))
for i := len(order) - 1; i >= 0; i-- {
reversed = append(reversed, order[i])
}
alice.request("PUT", "/api/kanban/columns", map[string]any{"order": reversed}, 200)
after := alice.getJSON("/api/kanban/columns", 200)
if after[0]["id"] != reversed[0] || after[len(after)-1]["id"] != reversed[len(reversed)-1] {
t.Fatal("column reorder not applied")
}
// PUT /api/pages reorder.
p1 := alice.request("POST", "/api/pages", map[string]any{"title": "P1"}, 201)["id"].(string)
p2 := alice.request("POST", "/api/pages", map[string]any{"title": "P2"}, 201)["id"].(string)
p3 := alice.request("POST", "/api/pages", map[string]any{"title": "P3"}, 201)["id"].(string)
alice.request("PUT", "/api/pages", map[string]any{"order": []string{p3, p1, p2}}, 200)
pages := alice.getJSON("/api/pages", 200)
if pages[0]["id"] != p3 || pages[1]["id"] != p1 || pages[2]["id"] != p2 {
t.Fatal("pages reorder not applied:", pages)
}
// DELETE /api/tags/{id} owner success + BookmarkTag cascade.
tag := alice.request("POST", "/api/tags", map[string]any{"name": "DeleteMe"}, 201)["id"].(string)
putBookmark(alice, id, map[string]any{"tagIds": []string{tag}}, 200)
alice.request("DELETE", "/api/tags/"+tag, nil, 200)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); len(v["tags"].([]any)) != 0 {
t.Fatal("tag delete did not cascade bookmark_tags")
}
for _, tg := range alice.getJSON("/api/tags", 200) {
if tg["id"] == tag {
t.Fatal("deleted tag still listed")
}
}
// DELETE /api/signal-sources/{id} owner success.
source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "x"}}, 201)["id"].(string)
alice.request("DELETE", "/api/signal-sources/"+source, nil, 200)
if v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200); len(v["sources"].([]any)) != 0 {
t.Fatal("signal source still present after delete")
}
}
// Signout must revoke every issued cookie JWT, not just clear this browser's
// cookie: a token copied before signout stops working.
func TestSignoutRevokesIssuedTokens(t *testing.T) {
srv := testServer(t)
u := newAPIUser(t, srv, "revoke@computer.test")
creds, _ := json.Marshal(map[string]any{"email": "revoke@computer.test", "password": "coverage-test-password"})
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(creds))
req.Header.Set("Content-Type", "application/json")
res, e := u.c.Do(req)
if e != nil {
t.Fatal(e)
}
res.Body.Close()
var stale string
for _, c := range res.Cookies() {
if c.Name == "computer_session" {
stale = c.Value
}
}
if stale == "" {
t.Fatal("login did not set a session cookie")
}
u.request("POST", "/api/auth/signout", map[string]any{}, 200)
// Replay the pre-signout token directly (no jar): must be rejected.
replay := func(path string) int {
rq, _ := http.NewRequest("GET", srv.URL+path, nil)
rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale})
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
io.Copy(io.Discard, rs.Body)
return rs.StatusCode
}
if code := replay("/api/me"); code != 401 {
t.Fatalf("stale cookie accepted on /api/me: %d", code)
}
if code := replay("/api/bookmarks"); code != 401 {
t.Fatalf("stale cookie accepted on data route: %d", code)
}
rq, _ := http.NewRequest("GET", srv.URL+"/api/auth/session", nil)
rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale})
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
raw, _ := io.ReadAll(rs.Body)
if !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
t.Fatalf("stale cookie still resolves a session: %s", raw)
}
}
// One scheduler tick creates signals and marks sources checked (TEST-2).
func TestSignalSchedulerRunOnce(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "scheduler@computer.test")
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
mail := imapserver.New(memory.New())
mail.AllowInsecureAuth = true
go mail.Serve(listener)
defer mail.Close()
host, port, _ := net.SplitHostPort(listener.Addr().String())
pn, _ := strconv.Atoi(port)
alice.request("POST", "/api/settings/imap", map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"}, 200)
id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/scheduler", "imapQuery": "little"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201)
signals.RunOnce(context.Background(), d)
v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) == 0 {
t.Fatal("scheduler tick created no signals")
}
sources := v["sources"].([]any)
if len(sources) != 1 {
t.Fatalf("expected 1 source, got %d", len(sources))
}
src := sources[0].(map[string]any)
if src["lastStatus"] != "Checked" || src["lastCheckedAt"] == nil {
t.Fatal("scheduler did not mark source checked:", src)
}
// A second tick must not duplicate signals (dedup under the scheduler path).
before := len(v["signals"].([]any))
signals.RunOnce(context.Background(), d)
v = alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) != before {
t.Fatal("scheduler tick duplicated signals")
}
}
// Review CR-16: mailbox failures are scheduler failures. A pass whose mailbox
// cannot even load must record the per-source status, answer manual checks
// with the structured outcome (failed count, not a blanket 500), and drive
// the per-user backoff so the next ticks are skipped.
func TestSchedulerBacksOffAfterMailboxFailure(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "backoff@computer.test")
id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/backoff"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "anything"}}, 201)
// Deliberately no saved IMAP configuration: every pass fails in LoadMailbox.
v := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
if v["checked"] != float64(1) || v["failed"] != float64(1) {
t.Fatal("manual check did not report the structured failure outcome:", v)
}
src := func() map[string]any {
got := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
return got["sources"].([]any)[0].(map[string]any)
}
if status, _ := src()["lastStatus"].(string); !strings.HasPrefix(status, "Error:") {
t.Fatal("per-source failure status not recorded:", status)
}
s := signals.NewScheduler()
ctx := context.Background()
s.RunOnce(ctx, d) // tick 1: runs and fails → failures=1
s.RunOnce(ctx, d) // tick 2: 2%2==0 → runs and fails → failures=2
after := src()["lastCheckedAt"]
s.RunOnce(ctx, d) // tick 3: 3%4!=0 → backoff must skip it
if got := src()["lastCheckedAt"]; got != after {
t.Fatalf("backoff did not skip tick 3: lastCheckedAt changed %v → %v", after, got)
}
}
// Page saves enforce optimistic concurrency (review DATA-01/DATA-05): a stale
// base version is rejected with 409 and a missing version with 400, both
// leaving the row untouched; content saves bump the revision, and metadata
// patches neither require nor bump it. The list projection omits content
// bodies (review PERF-01).
func TestPageVersionAndListProjection(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pagever@computer.test")
created := alice.request("POST", "/api/pages", map[string]any{"title": "Doc", "content": "first"}, 201)
id := created["id"].(string)
if created["version"].(float64) != 1 {
t.Fatalf("new page did not start at version 1: %v", created["version"])
}
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "stale tab", "version": 99}, 409)
if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "first" {
t.Fatal("rejected stale write still modified content")
}
saved := alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "second", "version": 1}, 200)
if saved["version"].(float64) != 2 {
t.Fatalf("content save did not bump version: %v", saved["version"])
}
// Review DATA-05: omitting the version cannot bypass concurrency — the
// write is rejected and the row is untouched. Metadata patches stay
// version-free (they do not bump the revision either).
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "unversioned clobber"}, 400)
if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "second" || v["version"].(float64) != 2 {
t.Fatalf("version-less content write mutated the page: %v", v)
}
pin := alice.request("PATCH", "/api/pages/"+id, map[string]any{"pinned": true}, 200)
if pin["version"].(float64) != 2 {
t.Fatalf("metadata patch changed the revision: %v", pin["version"])
}
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "third", "version": 1}, 409)
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "x", "version": -1}, 400)
found := false
for _, p := range alice.getJSON("/api/pages", 200) {
if p["id"] != id {
continue
}
found = true
if _, ok := p["content"]; ok {
t.Fatal("list projection still includes content")
}
if p["title"] != "Doc" || p["_count"] == nil {
t.Fatal("list projection lost metadata:", p)
}
}
if !found {
t.Fatal("page missing from list")
}
}
// Review CR-18: a content-save acknowledgement must describe the revision
// THIS write produced. Concurrent writers racing on the same base: exactly
// one wins and its response reports base+1 (the snapshot is read inside the
// write transaction), every loser gets 409 — no acknowledgement can carry a
// competing writer's committed revision.
func TestConcurrentPageSaveAckReportsOwnWrite(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pageack@computer.test")
id := alice.request("POST", "/api/pages", map[string]any{"title": "Ack", "content": "v1"}, 201)["id"].(string)
const writers = 8
type outcome struct {
status int
version float64
}
results := make(chan outcome, writers)
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
body, _ := json.Marshal(map[string]any{"content": fmt.Sprintf("writer-%d", n), "version": 1})
rq, _ := http.NewRequest("PATCH", srv.URL+"/api/pages/"+id, bytes.NewReader(body))
rq.Header.Set("Content-Type", "application/json")
rs, e := alice.c.Do(rq)
if e != nil {
results <- outcome{status: -1}
return
}
defer rs.Body.Close()
raw, _ := io.ReadAll(rs.Body)
out := outcome{status: rs.StatusCode}
var v map[string]any
if json.Unmarshal(raw, &v) == nil {
out.version, _ = v["version"].(float64)
}
results <- out
}(i)
}
wg.Wait()
close(results)
wins := 0
for o := range results {
switch o.status {
case 200:
wins++
if o.version != 2 {
t.Fatalf("winning acknowledgement reported version %v, want exactly base+1 = 2", o.version)
}
case 409:
default:
t.Fatalf("concurrent writer got unexpected status %d", o.status)
}
}
if wins != 1 {
t.Fatalf("expected exactly one winning writer, got %d", wins)
}
}
// Review CR-04: bookmark updates enforce optimistic concurrency — a missing
// version is 400, a stale base is 409 with the row untouched, success bumps
// the revision, and tag replacement rides the same contract.
func TestBookmarkVersionContract(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "bmver@computer.test")
created := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/ver", "title": "v0"}, 201)
id := created["id"].(string)
if created["version"].(float64) != 0 {
t.Fatalf("new bookmark did not start at version 0: %v", created["version"])
}
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "no version"}, 400)
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "stale", "version": 5}, 409)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "v0" || v["version"].(float64) != 0 {
t.Fatalf("rejected writes modified the bookmark: %v", v)
}
saved := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "v1", "version": 0}, 200)
if saved["version"].(float64) != 1 {
t.Fatalf("successful update did not bump the version: %v", saved["version"])
}
// A second writer holding the same base must not both succeed.
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "late", "version": 0}, 409)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "versioned"}, 201)["id"].(string)
out := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"tagIds": []string{tag}, "version": 1}, 200)
if out["version"].(float64) != 2 {
t.Fatalf("tag replacement did not advance the version: %v", out["version"])
}
if tags, ok := out["tags"].([]any); !ok || len(tags) != 1 {
t.Fatalf("tag replacement lost tags: %v", out["tags"])
}
}
// Assertions ride the tokenVersion (review SEC-02): "sign out everywhere"
// must revoke NextAuth-backed Bearer assertions, not just Go cookie sessions.
func TestAssertionRevocationFollowsTokenVersion(t *testing.T) {
srv, d := testServerDB(t)
u := newAPIUser(t, srv, "assert-revoke@computer.test")
uid := u.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
var ver int
if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&ver); e != nil {
t.Fatal(e)
}
sign := func(v int) string {
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.SessionClaims{
UserID: uid, Type: auth.TokenTypeAssertion, Ver: v,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "computer",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)),
},
})
s, e := tok.SignedString([]byte("integration-test-secret"))
if e != nil {
t.Fatal(e)
}
return s
}
bearer := func(token string) int {
rq, _ := http.NewRequest("GET", srv.URL+"/api/me", nil)
rq.Header.Set("Authorization", "Bearer "+token)
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
io.Copy(io.Discard, rs.Body)
return rs.StatusCode
}
if code := bearer(sign(ver)); code != 200 {
t.Fatalf("current-version assertion rejected: %d", code)
}
if code := bearer(sign(0)); code != 401 {
t.Fatalf("version-less assertion accepted: %d", code)
}
u.request("POST", "/api/auth/signout", map[string]any{}, 200)
if code := bearer(sign(ver)); code != 401 {
t.Fatalf("assertion minted before the revocation bump still accepted: %d", code)
}
var bumped int
if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&bumped); e != nil {
t.Fatal(e)
}
if code := bearer(sign(bumped)); code != 200 {
t.Fatalf("assertion with the post-bump version rejected: %d", code)
}
}
+220
View File
@@ -0,0 +1,220 @@
package http
import (
"bytes"
"encoding/json"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"testing"
)
// Integration tests create and drop only their own random PostgreSQL schema.
// They never truncate application tables or delete pre-existing accounts.
func testServer(t *testing.T) *httptest.Server {
t.Helper()
srv, _ := testServerDB(t)
return srv
}
func testServerDB(t *testing.T) (*httptest.Server, *db.DB) {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration tests")
}
// The limiter stores are process-global and login has no loopback
// exemption (review SEC-1); give every test a clean slate.
auth.ResetRateLimits()
t.Cleanup(auth.ResetRateLimits)
t.Setenv("AUTH_SECRET", "integration-test-secret")
t.Setenv("NEXTAUTH_SECRET", "integration-test-secret")
t.Setenv("ADMIN_EMAILS", "")
// CardDAV/IMAP fakes listen on loopback; netguard blocks private ranges by
// default (production compose never sets this).
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
// The IMAP fixture is a plaintext loopback server; production refuses
// non-TLS mailboxes (review NET-02) and this flag is the test-only escape
// hatch (see signals.AllowInsecure).
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1")
admin, e := db.New(&config.Config{DatabaseURL: dsn})
if e != nil {
t.Fatal(e)
}
schema := "test_" + db.NewID()
if _, e = admin.Exec(`CREATE SCHEMA ` + db.Quote(schema)); e != nil {
t.Fatal(e)
}
u, e := url.Parse(dsn)
if e != nil {
t.Fatal(e)
}
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
d, e := db.New(&config.Config{DatabaseURL: u.String()})
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { d.Close(); admin.Exec(`DROP SCHEMA ` + db.Quote(schema) + ` CASCADE`); admin.Close() })
raw, e := os.ReadFile("../../db/schema.sql")
if e != nil {
t.Fatal(e)
}
if _, e = d.Exec(string(raw)); e != nil {
t.Fatal(e)
}
srv := httptest.NewServer(NewServer(config.Load(), d).Handler)
t.Cleanup(srv.Close)
return srv, d
}
type apiClient struct {
t *testing.T
c *http.Client
base string
}
func (a apiClient) request(method, path string, body any, status int) map[string]any {
a.t.Helper()
var b io.Reader
if body != nil {
raw, _ := json.Marshal(body)
b = bytes.NewReader(raw)
}
req, _ := http.NewRequest(method, a.base+path, b)
req.Header.Set("Content-Type", "application/json")
res, e := a.c.Do(req)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("%s %s: wanted %d, got %d: %s", method, path, status, res.StatusCode, raw)
}
var v map[string]any
json.Unmarshal(raw, &v)
return v
}
// meID resolves the authenticated client's user id. Signup responses no
// longer carry it (review CR-03: identical bodies for every accepted
// registration, so no unauthenticated existence oracle); identity comes from
// the session after login.
func meID(c apiClient) string {
c.t.Helper()
return c.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
}
// putBookmark sends PUT /api/bookmarks/{id} with the bookmark's CURRENT
// version attached (review CR-04: the concurrency contract requires it).
func putBookmark(c apiClient, id string, body map[string]any, status int) map[string]any {
c.t.Helper()
cur := c.request("GET", "/api/bookmarks/"+id, nil, 200)
v, _ := cur["version"].(float64)
body["version"] = int(v)
return c.request("PUT", "/api/bookmarks/"+id, body, status)
}
func TestApplicationFlows(t *testing.T) {
srv := testServer(t)
newClient := func() apiClient { jar, _ := cookiejar.New(nil); return apiClient{t, &http.Client{Jar: jar}, srv.URL} }
alice, bob, anonymous := newClient(), newClient(), newClient()
post := func(email string) map[string]any {
return map[string]any{"email": email, "password": "a-long-test-password"}
}
alice.request("POST", "/api/signup", post("alice@computer.test"), 201)
bob.request("POST", "/api/signup", post("bob@computer.test"), 201)
// Review SEC-4/CR-03: a duplicate signup returns the SAME 201 {"ok":true}
// body as a fresh creation (no 409, no distinguishing field).
dup := alice.request("POST", "/api/signup", post("alice@computer.test"), 201)
if len(dup) != 1 || dup["ok"] != true {
t.Fatalf("duplicate signup body differs from a fresh creation: %v", dup)
}
alice.request("POST", "/api/auth/login", post("alice@computer.test"), 200)
bob.request("POST", "/api/auth/login", post("bob@computer.test"), 200)
au := meID(alice)
bu := meID(bob)
if alice.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("first signup was not admin")
}
anonymous.request("GET", "/api/bookmarks", nil, 401)
bob.request("GET", "/api/admin/users", nil, 403)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Research"}, 201)["id"].(string)
b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/link", "title": "Original", "notes": "# Notes", "tagIds": []string{tag}, "dueDate": "2026-10-07", "alertAt": "2000-01-01"}, 201)
id := b["id"].(string)
bob.request("GET", "/api/bookmarks/"+id, nil, 404)
bob.request("PUT", "/api/bookmarks/"+id, map[string]any{"notes": "attack"}, 404)
putBookmark(alice, id, map[string]any{"dueDate": nil, "isPublic": false}, 200)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "Original" || v["dueDate"] != nil {
t.Fatal("partial/null update broken")
}
putBookmark(alice, id, map[string]any{"tagIds": []string{"missing"}}, 400)
v := alice.request("GET", "/api/bookmarks/"+id, nil, 200)
if len(v["tags"].([]any)) != 1 {
t.Fatal("failed update lost tags")
}
alice.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{bu}, "isPublic": true}, 200)
bob.request("GET", "/api/bookmarks/"+id, nil, 200)
anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 200)
bob.request("DELETE", "/api/bookmarks/"+id, nil, 404)
alice.request("PUT", "/api/tags/"+tag, map[string]any{"shareUserIds": []string{bu}}, 200)
bob.request("GET", "/api/bookmarks?tagId="+tag, nil, 200)
page := bob.request("POST", "/api/pages", map[string]any{"title": "Workspace", "content": "Markdown text"}, 201)["id"].(string)
bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200)
bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200)
v = bob.request("GET", "/api/pages/"+page, nil, 200)
if len(v["bookmarks"].([]any)) != 1 {
t.Fatal("duplicate page link")
}
bob.request("GET", "/api/pages/"+page+"/markdown", nil, 200)
alice.request("GET", "/api/kanban/columns", nil, 200)
col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Custom"}, 201)["id"].(string)
putBookmark(alice, id, map[string]any{"kanbanColumnId": col}, 200)
v = alice.request("DELETE", "/api/kanban/columns/"+col, nil, 200)
if v["moved"] != float64(1) {
t.Fatal("deleted column did not move card")
}
alice.request("GET", "/api/bookmarks/"+id, nil, 200)
alice.request("PUT", "/api/kanban/columns", map[string]any{"order": []string{"not-mine"}}, 400)
alice.request("GET", "/api/alerts/check", nil, 200)
alice.request("DELETE", "/api/alerts/"+id, nil, 200)
v = alice.request("GET", "/api/bookmarks/"+id, nil, 200)
if v["alertSent"] != true {
t.Fatal("alert dismissal")
}
putBookmark(alice, id, map[string]any{"alertAt": "2000-01-02"}, 200)
source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "sender@computer.test"}}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
alice.request("PATCH", "/api/signal-sources/"+source, map[string]any{"enabled": false}, 200)
bob.request("DELETE", "/api/signal-sources/"+source, nil, 404)
alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": "encrypted-password"}, 200)
v = alice.request("GET", "/api/settings/imap", nil, 200)
if v["password"] != "••••••••" {
t.Fatal("mailbox password exposed")
}
alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": ""}, 200)
alice.request("POST", "/api/shared-dates", map[string]any{"date": "2026-10-07", "recipientIds": []string{bu}}, 201)
bob.request("GET", "/api/shared-dates", nil, 200)
alice.request("POST", "/api/admin/view-as", map[string]any{"userId": bu}, 200)
v = alice.request("GET", "/api/me", nil, 200)
if v["effectiveUser"].(map[string]any)["id"] != bu || v["user"].(map[string]any)["id"] != au {
t.Fatal("view-as identity")
}
alice.request("GET", "/api/admin/users", nil, 200)
alice.request("GET", "/api/pages/"+page, nil, 200)
alice.request("POST", "/api/admin/view-as", map[string]any{"userId": nil}, 200)
alice.request("PATCH", "/api/admin/users", map[string]any{"userId": au, "role": "USER"}, 400)
alice.request("GET", "/api/auth/oidc/status", nil, 200)
alice.request("GET", "/api/auth/callback/authelia", nil, 501)
bob.request("DELETE", "/api/pages/"+page, nil, 200)
alice.request("DELETE", "/api/bookmarks/"+id, nil, 200)
anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 404)
alice.request("POST", "/api/auth/signout", map[string]any{}, 200)
alice.request("GET", "/api/me", nil, 401)
}
+251
View File
@@ -0,0 +1,251 @@
package http
import (
"context"
"encoding/json"
crypt "github.com/andrew/go-computer/internal/crypto"
"github.com/andrew/go-computer/internal/signals"
"github.com/emersion/go-imap/backend/memory"
imapserver "github.com/emersion/go-imap/server"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
)
func TestExternalIntegrations(t *testing.T) {
srv, d := testServerDB(t)
jar, _ := cookiejar.New(nil)
a := apiClient{t, &http.Client{Jar: jar}, srv.URL}
credentials := map[string]any{"email": "integrations@computer.test", "password": "test-password"}
a.request("POST", "/api/signup", credentials, 201)
a.request("POST", "/api/auth/login", credentials, 200)
// CardDAV fixture (reviews CR-07/CR-08): the served multistatus is
// mutable so one test can walk the reconciliation policy — full snapshot,
// partially-withheld response, remote deletion, empty response.
var davMu sync.Mutex
var davBody string
setBody := func(body string) { davMu.Lock(); davBody = body; davMu.Unlock() }
getBody := func() string { davMu.Lock(); defer davMu.Unlock(); return davBody }
dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "REPORT" || r.Header.Get("Depth") != "1" {
t.Error("invalid CardDAV request")
}
u, p, _ := r.BasicAuth()
if u != "nextcloud-user" || p != "app-password" {
t.Error("CardDAV authentication")
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(207)
io.WriteString(w, getBody())
}))
defer dav.Close()
card1 := `<d:response><d:href>/card.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
UID:contact-1
FN:Contact Name
EMAIL:last@computer.test
EMAIL;TYPE=PREF:preferred@computer.test
TEL:1234
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
card2 := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
UID:contact-2
FN:Second Contact
EMAIL:second@computer.test
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
card2Withheld := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>`
wrap := func(responses ...string) string {
return `<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">` + strings.Join(responses, "") + `</d:multistatus>`
}
contactsOf := func() []map[string]any {
t.Helper()
res, e := a.c.Get(srv.URL + "/api/contacts")
if e != nil {
t.Fatal(e)
}
defer res.Body.Close()
var contacts []map[string]any
json.NewDecoder(res.Body).Decode(&contacts)
return contacts
}
req := map[string]any{"nextcloudUrl": dav.URL, "username": "nextcloud-user", "password": "app-password"}
// Full snapshot: both cards import; the PREF occurrence wins the repeated
// EMAIL property (review CR-08 — deterministic selection, no data loss).
setBody(wrap(card1, card2))
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) {
t.Fatal("CardDAV sync", v)
}
contacts := contactsOf()
if len(contacts) != 2 {
t.Fatal("CardDAV import count", contacts)
}
for _, c := range contacts {
if c["uid"] == "contact-1" && c["email"] != "preferred@computer.test" {
t.Fatal("repeated EMAIL did not resolve to the PREF value:", c)
}
}
// Idempotent re-sync: nothing removed, nothing duplicated.
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) {
t.Fatal("CardDAV re-sync", v)
}
if len(contactsOf()) != 2 {
t.Fatal("CardDAV re-sync duplicated contacts")
}
// Partially-withheld response (card2's data 404s): must NOT be read as a
// remote deletion (review CR-07 — never delete from a partial snapshot).
setBody(wrap(card1, card2Withheld))
if v := a.request("POST", "/api/contacts/sync", req, 200); v["removed"] != float64(0) {
t.Fatal("partial response triggered reconciliation:", v)
}
if len(contactsOf()) != 2 {
t.Fatal("partial response deleted a contact")
}
// Complete snapshot without card2: the remote deletion is mirrored.
setBody(wrap(card1))
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(1) || v["removed"] != float64(1) {
t.Fatal("CardDAV deletion reconciliation", v)
}
contacts = contactsOf()
if len(contacts) != 1 || contacts[0]["uid"] != "contact-1" {
t.Fatal("wrong contact survived reconciliation:", contacts)
}
// Empty response: the guard preserves everything (a server fault that
// answers 207 with zero cards must not wipe the address book).
setBody(wrap())
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(0) || v["removed"] != float64(0) {
t.Fatal("empty response reconciliation", v)
}
if len(contactsOf()) != 1 {
t.Fatal("empty response deleted contacts")
}
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
mail := imapserver.New(memory.New())
mail.AllowInsecureAuth = true
go mail.Serve(listener)
defer mail.Close()
host, port, _ := net.SplitHostPort(listener.Addr().String())
pn, _ := strconv.Atoi(port)
mailbox := map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"}
a.request("POST", "/api/imap/test", mailbox, 200)
a.request("POST", "/api/settings/imap", mailbox, 200)
id := a.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "imapQuery": "little"}, 201)["id"].(string)
a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201)
v := a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
if v["created"] != float64(1) {
t.Fatal("IMAP signal creation", v)
}
// Review SYNC-02: the pass persisted a UID checkpoint for the source.
var lastUID int64
if e := d.QueryRow(`SELECT "lastUid" FROM "SignalCheckpoint"`).Scan(&lastUID); e != nil {
t.Fatal("checkpoint not persisted:", e)
}
if lastUID < 1 {
t.Fatal("checkpoint did not advance:", lastUID)
}
v = a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
if v["created"] != float64(0) {
t.Fatal("signal deduplication", v)
}
v = a.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) != 1 {
t.Fatal("IMAP envelope fetch")
}
putBookmark(a, id, map[string]any{"imapWatchEnabled": true}, 200)
v = a.request("GET", "/api/imap/check", nil, 200)
if len(v["matches"].([]any)) != 1 {
t.Fatal("legacy mailbox watch")
}
}
// CardDAV transport (review recheck): Basic-auth credentials must never
// travel plaintext. Without the netguard fixture escape hatch, an http
// Nextcloud URL is rejected at the API boundary — no dial, no credentials.
func TestCardDAVRequiresTLSOutsideFixtures(t *testing.T) {
srv := testServer(t)
a := newAPIUser(t, srv, "carddav-tls@computer.test")
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "")
a.request("POST", "/api/contacts/sync", map[string]any{
"nextcloudUrl": "http://nextcloud.example.test", "username": "u", "password": "p",
}, 400)
// https passes the transport gate (the dial itself fails: no such host
// through netguard — the point is the 400 above never happens for TLS).
resp := a.request("POST", "/api/contacts/sync", map[string]any{
"nextcloudUrl": "https://nextcloud.invalid", "username": "u", "password": "p",
}, 400)
if resp == nil {
t.Fatal("expected a connect-level rejection, not a transport-policy one")
}
}
// Review CR-21: credential writes must never restore a stale password. The
// lazy ENCRYPTION_KEY migration re-encrypts with a compare-and-swap against
// the exact ciphertext it read, and a blank/masked settings save no longer
// rewrites the password column at all.
func TestImapCredentialWritesNeverRestoreStalePasswords(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "imap-stale@computer.test")
t.Setenv("ENCRYPTION_KEY", "dedicated-encryption-key")
uid := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
// Seed a legacy row encrypted under the AUTH secret (pre-ENCRYPTION_KEY),
// exactly the shape the transparent migration expects.
legacy, e := crypt.Encrypt("legacy-password", "integration-test-secret")
if e != nil {
t.Fatal(e)
}
if _, e := d.Exec(`INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES('imap-cfg-1','legacy.example.test',993,true,'user',$1,'INBOX',$2)`, legacy, uid); e != nil {
t.Fatal(e)
}
// Loading migrates: the password decrypts and the row is re-encrypted
// under the dedicated key.
m, e := signals.LoadMailbox(context.Background(), d, uid)
if e != nil || m.Password != "legacy-password" {
t.Fatalf("legacy load: %v %+v", e, m)
}
var stored string
if e := d.QueryRow(`SELECT password FROM "ImapConfig" WHERE "userId"=$1`, uid).Scan(&stored); e != nil {
t.Fatal(e)
}
if p, e := crypt.Decrypt(stored, "dedicated-encryption-key"); e != nil || p != "legacy-password" {
t.Fatalf("row was not migrated under ENCRYPTION_KEY: %v", e)
}
// The CAS guard: an update conditioned on a stale ciphertext touches no
// rows, so a concurrent replacement can never be rolled back by migration.
res, e := d.Exec(`UPDATE "ImapConfig" SET password=$1 WHERE "userId"=$2 AND password=$3`, "stale-rewrite", uid, legacy)
if e != nil {
t.Fatal(e)
}
if n, _ := res.RowsAffected(); n != 0 {
t.Fatal("compare-and-swap against a stale ciphertext updated the row")
}
// A concurrent replacement through the API survives...
alice.request("POST", "/api/settings/imap", map[string]any{"host": "new.example.test", "port": 993, "tls": true, "username": "user", "password": "replacement-password", "folder": "INBOX"}, 200)
// ...and a later masked save (connection settings only) must keep it.
alice.request("POST", "/api/settings/imap", map[string]any{"host": "masked.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 200)
m, e = signals.LoadMailbox(context.Background(), d, uid)
if e != nil {
t.Fatal(e)
}
if m.Password != "replacement-password" || m.Host != "masked.example.test" {
t.Fatalf("masked save clobbered the stored credentials: %+v", m)
}
// A masked save with no stored configuration is a 400, never an empty
// or resurrected password.
bob := newAPIUser(t, srv, "imap-nocfg@computer.test")
bob.request("POST", "/api/settings/imap", map[string]any{"host": "h.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 400)
}
+123
View File
@@ -0,0 +1,123 @@
package http
import "testing"
// Kanban ordering is persisted, not just a client-side illusion: a card move
// normalizes the target column, keeps legacy status-only cards in place, and
// still honors the bookmark concurrency version (review CR-04).
func TestKanbanCardMovePersistsOrdering(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "kanban-order@computer.test")
cols := alice.getJSON("/api/kanban/columns", 200)
if len(cols) < 2 {
t.Fatalf("expected default columns, got %d", len(cols))
}
inbox := cols[0]["id"].(string)
todo := cols[1]["id"].(string)
create := func(title, column string) map[string]any {
return alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/" + title,
"title": title,
"kanbanColumnId": column,
}, 201)
}
first := create("first", inbox)
second := create("second", inbox)
third := create("third", inbox)
// New cards start newest-first (updatedAt desc), so establish the known
// first/second/third order before asserting a reorder. Third stays at its
// original version for the move below.
for _, step := range []struct {
id string
position int
}{{first["id"].(string), 0}, {second["id"].(string), 1}} {
cur := alice.request("GET", "/api/bookmarks/"+step.id, nil, 200)
alice.request("PUT", "/api/kanban/cards/"+step.id, map[string]any{
"columnId": inbox, "position": step.position, "version": cur["version"],
}, 200)
}
// Move the newest card to the top of its own column.
moved := alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{
"columnId": inbox, "position": 0, "version": 0,
}, 200)
if moved["kanbanPosition"] != float64(0) || moved["version"] != float64(1) {
t.Fatalf("move did not persist position/version: %v", moved)
}
if moved["kanbanColumnId"] != inbox {
t.Fatalf("move lost column: %v", moved)
}
board := func(column string) []map[string]any {
t.Helper()
out := []map[string]any{}
for _, row := range alice.getJSON("/api/bookmarks", 200) {
if row["kanbanColumnId"] == column {
out = append(out, row)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j]["kanbanPosition"].(float64) < out[j-1]["kanbanPosition"].(float64); j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
order := board(inbox)
if len(order) != 3 {
t.Fatalf("expected 3 cards, got %d", len(order))
}
for i, want := range []string{third["id"].(string), first["id"].(string), second["id"].(string)} {
if order[i]["id"] != want {
t.Fatalf("position %d: got %v, want %s", i, order[i]["id"], want)
}
if order[i]["kanbanPosition"] != float64(i) {
t.Fatalf("card %s position = %v, want %d", order[i]["id"], order[i]["kanbanPosition"], i)
}
}
// Missing/stale versions are rejected without changing order.
alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{
"columnId": inbox, "position": 0,
}, 400)
alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{
"columnId": inbox, "position": 0, "version": 0,
}, 409)
// Cross-column moves also normalize the target and leave the source intact.
secondNow := alice.request("GET", "/api/bookmarks/"+second["id"].(string), nil, 200)
alice.request("PUT", "/api/kanban/cards/"+second["id"].(string), map[string]any{
"columnId": todo, "position": 0, "version": secondNow["version"],
}, 200)
if got := board(todo); len(got) != 1 || got[0]["id"] != second["id"] {
t.Fatalf("cross-column move failed: %v", got)
}
if got := board(inbox); len(got) != 2 || got[0]["id"] != third["id"] || got[1]["id"] != first["id"] {
t.Fatalf("source column order changed unexpectedly: %v", got)
}
// A legacy status-only card joins the target column's explicit ordering.
legacy := alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/legacy", "title": "legacy", "kanbanStatus": "TODO",
}, 201)
alice.request("PUT", "/api/kanban/cards/"+legacy["id"].(string), map[string]any{
"columnId": todo, "position": 0, "version": 0,
}, 200)
if got := board(todo); len(got) != 2 || got[0]["id"] != legacy["id"] || got[1]["id"] != second["id"] {
t.Fatalf("legacy card did not join explicit ordering: %v", got)
}
if got := alice.request("GET", "/api/bookmarks/"+legacy["id"].(string), nil, 200); got["kanbanColumnId"] != todo {
t.Fatalf("legacy card did not gain explicit membership: %v", got)
}
// Foreign columns are never accepted.
bob := newAPIUser(t, srv, "kanban-other@computer.test")
bobCols := bob.getJSON("/api/kanban/columns", 200)
foreign := bobCols[0]["id"].(string)
firstNow := alice.request("GET", "/api/bookmarks/"+first["id"].(string), nil, 200)
alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{
"columnId": foreign, "position": 0, "version": firstNow["version"],
}, 400)
}
+73
View File
@@ -0,0 +1,73 @@
package http
import (
"net/url"
"strings"
"testing"
)
// Review PERF-2: page search treats % and _ literally and is backed by the
// trigram indexes introduced alongside the escaping change.
func TestPageSearchEscapesWildcards(t *testing.T) {
srv, d := testServerDB(t)
alice, _ := accountClient(t, srv.URL, "pagesearch@computer.test")
other, _ := accountClient(t, srv.URL, "pagesearch-other@computer.test")
alice.request("POST", "/api/pages", map[string]any{"title": "100% done", "content": "literal percent"}, 201)
alice.request("POST", "/api/pages", map[string]any{"title": "100x done", "content": "not a percent"}, 201)
alice.request("POST", "/api/pages", map[string]any{"title": "snake_case", "content": "underscore"}, 201)
alice.request("POST", "/api/pages", map[string]any{"title": "snakeXcase", "content": "not an underscore"}, 201)
other.request("POST", "/api/pages", map[string]any{"title": "100% private", "content": "someone else"}, 201)
search := func(term string) []map[string]any {
return alice.getJSON("/api/pages?search="+url.QueryEscape(term), 200)
}
if got := search("100%"); len(got) != 1 || got[0]["title"] != "100% done" {
t.Fatalf("literal %% search returned %v", got)
}
if got := search("snake_"); len(got) != 1 || got[0]["title"] != "snake_case" {
t.Fatalf("literal _ search returned %v", got)
}
if got := search("100x"); len(got) != 1 || got[0]["title"] != "100x done" {
t.Fatalf("plain substring search returned %v", got)
}
for _, idx := range []string{"Page_title_trgm_idx", "Page_content_trgm_idx"} {
var n int
if e := d.QueryRow(`SELECT count(*) FROM pg_indexes WHERE schemaname=current_schema() AND indexname=$1`, idx).Scan(&n); e != nil || n != 1 {
t.Fatalf("missing page search index %s: n=%d err=%v", idx, n, e)
}
}
}
// Review "cap input sizes": per-field limits reject absurd values with a 400
// instead of persisting them (the 1 MiB body cap alone is too coarse).
func TestTextFieldSizeLimits(t *testing.T) {
srv := testServer(t)
alice, _ := accountClient(t, srv.URL, "limits@computer.test")
const (
urlLimit = 2048
titleLimit = 500
tagNameLimit = 80
contentLimit = 512 << 10
)
alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/" + strings.Repeat("u", urlLimit),
"title": "too long",
}, 400)
alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/ok",
"title": strings.Repeat("t", titleLimit+1),
}, 400)
alice.request("POST", "/api/tags", map[string]any{"name": strings.Repeat("n", tagNameLimit+1)}, 400)
alice.request("POST", "/api/pages", map[string]any{
"title": "huge",
"content": strings.Repeat("x", contentLimit+1),
}, 400)
// The boundary itself remains accepted.
if got := alice.request("POST", "/api/tags", map[string]any{"name": strings.Repeat("n", tagNameLimit)}, 201); got["name"] != strings.Repeat("n", tagNameLimit) {
t.Fatal("boundary-length tag rejected or altered")
}
}
+68
View File
@@ -0,0 +1,68 @@
package http
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// The ledgered migrations (review MIG-01) must stay idempotent: applying
// every file twice in order cannot change the backfill guarantees.
func TestUpgradeBackfillsLegacyWatchesWithoutChangingExistingSources(t *testing.T) {
srv, d := testServerDB(t)
_, uid := accountClient(t, srv.URL, "watcher@computer.test")
if _, err := d.Exec(`INSERT INTO "Bookmark"(id,url,"ownerId","imapWatchEnabled","imapQuery") VALUES('old','https://127.0.0.1',$1,true,'Invoices'),('paused','https://127.0.0.1',$1,true,'Receipts'),('off','https://127.0.0.1',$1,false,'Other')`, uid); err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`INSERT INTO "SignalSource"(id,type,config,enabled,"bookmarkId","ownerId") VALUES('modern','email','{"subject":"Modern"}',true,'old',$1),('existing','email','{"legacy":true,"subject":"Paused"}',false,'paused',$1)`, uid); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir("../../db/migrations")
if err != nil {
t.Fatal(err)
}
names := []string{}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
if len(names) == 0 {
t.Fatal("no migration files found")
}
for i := 0; i < 2; i++ {
for _, name := range names {
raw, err := os.ReadFile(filepath.Join("../../db/migrations", name))
if err != nil {
t.Fatal(err)
}
if _, err := d.Exec(string(raw)); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
}
var count int
if err := d.QueryRow(`SELECT count(*) FROM "SignalSource"`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("expected existing two sources and one backfill, got %d", count)
}
var enabled bool
var subject string
if err := d.QueryRow(`SELECT enabled,config->>'subject' FROM "SignalSource" WHERE id='legacy-old'`).Scan(&enabled, &subject); err != nil {
t.Fatal(err)
}
if !enabled || subject != "Invoices" {
t.Fatal("legacy switch not migrated")
}
if err := d.QueryRow(`SELECT enabled,config->>'subject' FROM "SignalSource" WHERE id='existing'`).Scan(&enabled, &subject); err != nil {
t.Fatal(err)
}
if enabled || subject != "Paused" {
t.Fatal("existing paused watcher modified")
}
}
+63
View File
@@ -0,0 +1,63 @@
package http
import (
"encoding/json"
"fmt"
"net/url"
"testing"
)
func TestBookmarkPaginationPreservesCollectionAndOwnership(t *testing.T) {
srv, d := testServerDB(t)
owner, uid := accountClient(t, srv.URL, "owner@computer.test")
other, oid := accountClient(t, srv.URL, "other@computer.test")
for _, user := range []string{uid, oid} {
if _, err := d.Exec(`INSERT INTO "Bookmark"(id,url,title,"ownerId","updatedAt") SELECT $1||'-'||lpad(n::text,4,'0'),'https://127.0.0.1/test','Match '||n,$1,'2026-10-07T12:00:00Z' FROM generate_series(1,205) n`, user); err != nil {
t.Fatal(err)
}
}
for _, c := range []apiClient{owner, other} {
seen := map[string]bool{}
cursor := ""
for {
res, err := c.c.Get(srv.URL + "/api/bookmarks?limit=100&search=Match&cursor=" + url.QueryEscape(cursor))
if err != nil {
t.Fatal(err)
}
var records []map[string]any
if err := json.NewDecoder(res.Body).Decode(&records); err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 200 || len(records) > 100 {
t.Fatal("invalid page", res.StatusCode, len(records))
}
for _, row := range records {
id := row["id"].(string)
if seen[id] {
t.Fatal("duplicate bookmark across pages", id)
}
seen[id] = true
}
cursor = res.Header.Get("X-Next-Cursor")
if cursor == "" {
break
}
}
if len(seen) != 205 {
t.Fatalf("wanted 205 bookmarks, got %d", len(seen))
}
expected := uid
if c.c == other.c {
expected = oid
}
for i := 1; i <= 205; i++ {
if !seen[fmt.Sprintf("%s-%04d", expected, i)] {
t.Fatal("bookmark missing or foreign collection included")
}
}
}
owner.request("GET", "/api/bookmarks?limit=201", nil, 400)
owner.request("GET", "/api/bookmarks?limit=0", nil, 400)
owner.request("GET", "/api/bookmarks?cursor=invalid", nil, 400)
}
+256
View File
@@ -0,0 +1,256 @@
package http
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"github.com/andrew/go-computer/internal/scripts"
"net/http"
"net/http/cookiejar"
"os"
"strconv"
"testing"
"time"
)
func TestConfiguredAdminCannotBeBypassedDuringBootstrap(t *testing.T) {
srv, d := testServerDB(t)
t.Setenv("ADMIN_EMAILS", "operator@computer.test")
visitor, _ := accountClient(t, srv.URL, "visitor@computer.test")
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("public signup bypassed reserved administrator bootstrap")
}
visitor.request("GET", "/api/admin/users", nil, 403)
t.Setenv("SEED_EMAIL", "operator@computer.test")
t.Setenv("SEED_PASSWORD", "operator-test-password")
if err := scripts.Seed(context.Background(), d); err != nil {
t.Fatal(err)
}
visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200)
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("operator-provisioned account is not admin")
}
t.Setenv("SEED_PASSWORD", "different-password")
if err := scripts.Seed(context.Background(), d); err != nil {
t.Fatal(err)
}
visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200)
}
func accountClient(t *testing.T, base, email string) (apiClient, string) {
t.Helper()
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, base}
body := map[string]any{"email": email, "password": "test-long-password"}
c.request("POST", "/api/signup", body, 201)
c.request("POST", "/api/auth/login", body, 200)
// Review CR-03: the signup body no longer carries the id; identity comes
// from the authenticated session.
return c, meID(c)
}
func TestAdminEnrollmentRequiresTrustedIdentity(t *testing.T) {
srv, d := testServerDB(t)
admin, _ := accountClient(t, srv.URL, "admin@computer.test")
user, uid := accountClient(t, srv.URL, "unverified@computer.test")
t.Setenv("ADMIN_EMAILS", "reserved@computer.test,unverified@computer.test")
// Reserved addresses answer exactly like existing accounts (no 403 oracle).
// Review SEC-4/CR-03: operator-reserved addresses return the SAME uniform
// 201 {"ok":true} body as every other accepted registration — no userId
// field, no 409.
reserved := user.request("POST", "/api/signup", map[string]any{"email": "reserved@computer.test", "password": "test-password"}, 201)
if len(reserved) != 1 || reserved["ok"] != true {
t.Fatalf("operator-reserved address body is distinguishable: %v", reserved)
}
if user.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("unverified address promoted to admin")
}
user.request("GET", "/api/admin/users", nil, 403)
if _, err := d.Exec(`UPDATE "User" SET "emailVerified"=now() WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
if user.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("verified allowlisted address was not promoted")
}
admin.request("GET", "/api/admin/users", nil, 200)
}
func TestPublicBookmarksDoNotExposePrivateMetadata(t *testing.T) {
srv, d := testServerDB(t)
owner, uid := accountClient(t, srv.URL, "owner@computer.test")
viewer, vid := accountClient(t, srv.URL, "viewer@computer.test")
if _, err := d.Exec(`INSERT INTO "Contact"(id,uid,"displayName",email,phone,"userId") VALUES('contact','vcard','Private','private@computer.test','555-private',$1)`, uid); err != nil {
t.Fatal(err)
}
id := owner.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "isPublic": true, "contactId": "contact", "imapQuery": "private mailbox query", "alertAt": "2026-10-07"}, 201)["id"].(string)
v := viewer.request("GET", "/api/public/bookmarks/"+id, nil, 200)
// Review PRIV-04: public links carry no owner attribution at all; the
// README promises owner identity stays private.
for _, key := range []string{"contact", "contactId", "sharedWith", "owner", "ownerId", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition", "pageLinks"} {
if _, exists := v[key]; exists {
t.Errorf("public response includes %s", key)
}
}
viewer.request("GET", "/api/bookmarks/"+id, nil, 404)
owner.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{vid}}, 200)
// Review PRIV-02: an authenticated shared reader gets the narrowed
// projection — no contact record, no recipient list, no owner operational
// metadata — while the owner keeps the full view.
shared := viewer.request("GET", "/api/bookmarks/"+id, nil, 200)
for _, key := range []string{"contact", "contactId", "sharedWith", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition"} {
if _, exists := shared[key]; exists {
t.Errorf("shared-reader response includes %s", key)
}
}
if shared["url"] != "https://127.0.0.1/test" || shared["ownerId"] != uid {
t.Error("shared-reader response lost basic bookmark fields")
}
if _, exists := shared["tags"]; !exists {
t.Error("shared-reader response lost tags")
}
full := owner.request("GET", "/api/bookmarks/"+id, nil, 200)
if c, _ := full["contact"].(map[string]any); c == nil || c["email"] != "private@computer.test" {
t.Error("owner projection lost the contact record")
}
if s, ok := full["sharedWith"].([]any); !ok || len(s) != 1 {
t.Error("owner projection lost the recipient list")
}
if _, exists := full["imapQuery"]; !exists {
t.Error("owner projection lost mailbox-watch settings")
}
// The list endpoint must apply the same permission-specific projections.
res, err := viewer.c.Get(srv.URL + "/api/bookmarks?shared=true")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var list []map[string]any
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0]["id"] != id {
t.Fatalf("shared list returned %d entries", len(list))
}
for _, key := range []string{"contact", "sharedWith", "imapQuery", "alertAt"} {
if _, exists := list[0][key]; exists {
t.Errorf("shared list response includes %s", key)
}
}
putBookmark(owner, id, map[string]any{"isPublic": false}, 200)
viewer.request("GET", "/api/public/bookmarks/"+id, nil, 404)
}
func TestAdminBootstrapIsOneShot(t *testing.T) {
srv, d := testServerDB(t)
// Fresh installation: the first signup completes bootstrap as admin.
admin, adminID := accountClient(t, srv.URL, "first@computer.test")
if admin.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("first signup on a fresh installation was not admin")
}
// Review AUTH-06 acceptance: demoting the last administrator must not
// re-open administrator assignment through signup.
if _, err := d.Exec(`UPDATE "User" SET role='USER' WHERE id=$1`, adminID); err != nil {
t.Fatal(err)
}
latecomer, _ := accountClient(t, srv.URL, "late@computer.test")
if latecomer.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("signup re-opened admin bootstrap after the last admin was demoted")
}
// Removing every administrator entirely does not re-open it either.
if _, err := d.Exec(`DELETE FROM "User" WHERE id=$1`, adminID); err != nil {
t.Fatal(err)
}
successor, _ := accountClient(t, srv.URL, "successor@computer.test")
if successor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("signup re-opened admin bootstrap after the last admin was removed")
}
successor.request("GET", "/api/admin/users", nil, 403)
}
// Review SEC-5: with ALLOW_BOOTSTRAP_SIGNUP=0 a fresh installation must not
// hand administrator to the first signup — provisioning stays operator-driven.
func TestBootstrapSignupCanBeDisabled(t *testing.T) {
srv := testServer(t)
t.Setenv("ALLOW_BOOTSTRAP_SIGNUP", "0")
visitor, _ := accountClient(t, srv.URL, "unclaimed@computer.test")
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("first signup became admin despite ALLOW_BOOTSTRAP_SIGNUP=0")
}
visitor.request("GET", "/api/admin/users", nil, 403)
}
func TestDirectLoginRejectsBrowserForgery(t *testing.T) {
srv := testServer(t)
for _, tc := range []struct {
contentType, origin string
status int
}{
{"text/plain", "https://attacker.invalid", 415},
{"application/json", "https://attacker.invalid", 403},
} {
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewBufferString(`{"email":"attacker@computer.test","password":"password"}`))
req.Header.Set("Content-Type", tc.contentType)
req.Header.Set("Origin", tc.origin)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != tc.status || len(res.Cookies()) != 0 {
t.Fatalf("forged login: status=%d cookies=%v", res.StatusCode, res.Cookies())
}
}
}
// Review SEC-1/SEC-2: the failure lockout keys on the proxy-attested browser
// IP. Two different signed identities get independent lockout buckets; a
// replayed (stale-timestamp) signature is not honored.
func TestLoginLockoutHonorsSignedForwardedIdentity(t *testing.T) {
srv := testServer(t)
secret := os.Getenv("AUTH_SECRET")
sign := func(ip string, ts int64) (string, string, string) {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
return ip, strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
}
post := func(email, password, ip string, ts int64) int {
body, _ := json.Marshal(map[string]any{"email": email, "password": password})
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if ip != "" {
hip, hts, sig := sign(ip, ts)
req.Header.Set("X-Computer-Client-Ip", hip)
req.Header.Set("X-Computer-Client-Ts", hts)
req.Header.Set("X-Computer-Client-Sig", sig)
}
res, e := http.DefaultClient.Do(req)
if e != nil {
t.Fatal(e)
}
res.Body.Close()
return res.StatusCode
}
now := time.Now().Unix()
// Browser A: five failures lock out its (IP, email) bucket.
for i := 0; i < 5; i++ { // failureThreshold
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 401 {
t.Fatalf("browser A attempt %d: %d", i+1, code)
}
}
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 429 {
t.Fatalf("browser A not locked out: %d", code)
}
// Browser B behind the same proxy peer keeps its own buckets.
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.2", now); code != 401 {
t.Fatalf("browser B locked out by browser A's failures: %d", code)
}
// A stale signature (outside the ~60s window) falls back to the peer, so
// it cannot smuggle a fresh identity: these attempts count against the
// peer bucket and stay 401.
if code := post("victim-b@computer.test", "wrong-password", "198.51.100.9", now-3600); code != 401 {
t.Fatalf("stale signature changed behavior: %d", code)
}
}
+181
View File
@@ -0,0 +1,181 @@
package http
import (
"context"
"net/http"
"os"
"time"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/authguard"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/handlers"
"github.com/andrew/go-computer/internal/oidc"
)
// NewServer builds the HTTP server with all routes registered.
// Porting guide section 4 — every data endpoint resolves the effective user,
// returns 401 if none, checks ownership, returns 404 if not theirs, then acts.
//
// All routes return JSON unless marked .md. Errors are {error: string}.
func NewServer(cfg *config.Config, database *db.DB) *http.Server {
mux := http.NewServeMux()
// Health / readiness. No auth, no DB.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := database.PingContext(ctx); err != nil {
handlers.WriteJSON(w, 503, handlers.Error{Error: "Database unavailable"})
return
}
handlers.WriteJSON(w, 200, map[string]string{"status": "ready"})
})
// 4.1 Auth and account (rate limited for non-loopback peers)
mux.HandleFunc("POST /api/signup", auth.RateLimitSignup(auth.HandleSignup))
mux.HandleFunc("POST /api/auth/login", auth.RateLimitLogin(auth.HandleLogin))
mux.HandleFunc("GET /api/auth/", auth.HandleAuth) // csrf, callbacks, session, signout
mux.HandleFunc("POST /api/auth/", auth.HandleAuth)
mux.HandleFunc("GET /api/me", authed(auth.HandleMe))
// Authelia OIDC (stub-aware; the client drives the flow via NextAuth)
mux.HandleFunc("GET /api/auth/oidc/status", oidc.HandleStatus)
mux.HandleFunc("GET /api/auth/oidc/login", oidc.HandleLogin)
mux.HandleFunc("GET /api/auth/callback/authelia", oidc.HandleCallback)
mux.HandleFunc("POST /api/auth/callback/authelia", oidc.HandleCallback)
// 4.2 Admin (use real user; 403 unless ADMIN)
mux.HandleFunc("GET /api/admin/users", admin(authguard.HandleAdminListUsers))
mux.HandleFunc("PATCH /api/admin/users", admin(authguard.HandleAdminPatchUser))
mux.HandleFunc("POST /api/admin/view-as", admin(authguard.HandleAdminViewAs))
// 4.3 Bookmarks
mux.HandleFunc("GET /api/bookmarks", authed(handlers.HandleBookmarksList))
mux.HandleFunc("POST /api/bookmarks", authed(handlers.HandleBookmarksCreate))
mux.HandleFunc("GET /api/bookmarks/{id}", authed(handlers.HandleBookmarksGet))
mux.HandleFunc("PUT /api/bookmarks/{id}", authed(handlers.HandleBookmarksUpdate))
mux.HandleFunc("DELETE /api/bookmarks/{id}", authed(handlers.HandleBookmarksDelete))
mux.HandleFunc("POST /api/bookmarks/{id}/share", authed(handlers.HandleBookmarksShare))
mux.HandleFunc("DELETE /api/bookmarks/{id}/share/{userId}", authed(handlers.HandleBookmarkUnshare))
mux.HandleFunc("GET /api/bookmarks/{id}/markdown", authed(handlers.HandleBookmarksMarkdown))
// 4.4 Tags
mux.HandleFunc("GET /api/tags", authed(handlers.HandleTagsList))
mux.HandleFunc("POST /api/tags", authed(handlers.HandleTagsCreate))
mux.HandleFunc("PUT /api/tags/{id}", authed(handlers.HandleTagsUpdate))
mux.HandleFunc("DELETE /api/tags/{id}", authed(handlers.HandleTagsDelete))
mux.HandleFunc("DELETE /api/tags/{id}/share/{userId}", authed(handlers.HandleTagUnshare))
// 4.5 Kanban columns
mux.HandleFunc("GET /api/kanban/columns", authed(handlers.HandleKanbanColumnsList))
mux.HandleFunc("POST /api/kanban/columns", authed(handlers.HandleKanbanColumnsCreate))
mux.HandleFunc("PUT /api/kanban/columns", authed(handlers.HandleKanbanColumnsReorder))
mux.HandleFunc("PATCH /api/kanban/columns/{id}", authed(handlers.HandleKanbanColumnsUpdate))
mux.HandleFunc("DELETE /api/kanban/columns/{id}", authed(handlers.HandleKanbanColumnsDelete))
mux.HandleFunc("PUT /api/kanban/cards/{id}", authed(handlers.HandleKanbanCardsMove))
// 4.6 Pages
mux.HandleFunc("GET /api/pages", authed(handlers.HandlePagesList))
mux.HandleFunc("POST /api/pages", authed(handlers.HandlePagesCreate))
mux.HandleFunc("PUT /api/pages", authed(handlers.HandlePagesReorder))
mux.HandleFunc("GET /api/pages/{id}", authed(handlers.HandlePagesGet))
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
// 4.7 Signals
mux.HandleFunc("GET /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsList))
mux.HandleFunc("POST /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsCreate))
mux.HandleFunc("PATCH /api/signal-sources/{id}", authed(handlers.HandleSignalSourcesUpdate))
mux.HandleFunc("DELETE /api/signal-sources/{id}", authed(handlers.HandleSignalSourcesDelete))
// 4.8 Alerts, contacts, email, dates, users
mux.HandleFunc("GET /api/alerts/check", authed(handlers.HandleAlertsCheck))
mux.HandleFunc("DELETE /api/alerts/{bookmarkId}", authed(handlers.HandleAlertsDismiss))
mux.HandleFunc("GET /api/contacts", authed(handlers.HandleContactsList))
mux.HandleFunc("POST /api/contacts/sync", authed(handlers.HandleContactsSync))
mux.HandleFunc("GET /api/settings/imap", authed(handlers.HandleImapGet))
mux.HandleFunc("POST /api/settings/imap", authed(handlers.HandleImapSave))
mux.HandleFunc("POST /api/imap/test", authed(handlers.HandleImapTest))
mux.HandleFunc("GET /api/imap/check", authed(handlers.HandleImapCheck))
mux.HandleFunc("GET /api/shared-dates", authed(handlers.HandleSharedDatesList))
mux.HandleFunc("POST /api/shared-dates", authed(handlers.HandleSharedDatesCreate))
mux.HandleFunc("DELETE /api/shared-dates/{id}", authed(handlers.HandleSharedDateDelete))
mux.HandleFunc("GET /api/users/search", authed(handlers.HandleUsersSearch))
// 4.9 Collections (Notion-like upgrades phase 2). Owner-scoped in this
// phase; membership never broadens bookmark access (N-08).
mux.HandleFunc("GET /api/collections", authed(handlers.HandleCollectionsList))
mux.HandleFunc("POST /api/collections", authed(handlers.HandleCollectionsCreate))
mux.HandleFunc("GET /api/collections/{id}", authed(handlers.HandleCollectionsGet))
mux.HandleFunc("PATCH /api/collections/{id}", authed(handlers.HandleCollectionsUpdate))
mux.HandleFunc("DELETE /api/collections/{id}", authed(handlers.HandleCollectionsDelete))
mux.HandleFunc("POST /api/collections/{id}/properties", authed(handlers.HandleCollectionPropertiesCreate))
mux.HandleFunc("PATCH /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesUpdate))
mux.HandleFunc("DELETE /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesDelete))
mux.HandleFunc("GET /api/collections/{id}/items", authed(handlers.HandleCollectionItemsList))
mux.HandleFunc("POST /api/collections/{id}/items", authed(handlers.HandleCollectionItemsCreate))
mux.HandleFunc("PUT /api/collections/{id}/items", authed(handlers.HandleCollectionItemsReorder))
mux.HandleFunc("PATCH /api/collections/{id}/items/{itemId}", authed(handlers.HandleCollectionItemsUpdate))
mux.HandleFunc("DELETE /api/collections/{id}/items/{itemId}", authed(handlers.HandleCollectionItemsDelete))
mux.HandleFunc("POST /api/collections/{id}/views", authed(handlers.HandleCollectionViewsCreate))
mux.HandleFunc("PATCH /api/collections/{id}/views/{viewId}", authed(handlers.HandleCollectionViewsUpdate))
mux.HandleFunc("DELETE /api/collections/{id}/views/{viewId}", authed(handlers.HandleCollectionViewsDelete))
// 4.10 Public page (no login)
// Review SEC-10: public endpoints get per-IP rate limiting (no auth
// context, so no per-user limit). 30 req/min for public bookmark lookup,
// 20 req/min for preview (heavier outbound work).
publicBookmarkLimiter := auth.NewLimiterStore()
previewLimiter := auth.NewLimiterStore()
publicLimit := auth.Limiter(publicBookmarkLimiter, 30, time.Minute)
previewLimit := auth.Limiter(previewLimiter, 20, time.Minute)
mux.HandleFunc("GET /share/bookmark/{id}", publicLimit(handlers.HandlePublicBookmark))
mux.HandleFunc("GET /api/public/bookmarks/{id}", publicLimit(handlers.HandlePublicBookmark))
mux.HandleFunc("GET /api/preview", previewLimit(authed(handlers.HandlePreview)))
return &http.Server{
Handler: withDB(database, mux),
Addr: ":" + port(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
// withDB carries the database into request context so handlers can pull it via
// db.FromContext. This keeps the DB out of global state (testable, swappable).
func withDB(database *db.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := db.NewContext(r.Context(), database)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// authed wraps a data route with session authentication (401 if no valid
// session). All data endpoints follow: resolve effective user -> 401 ->
// ownership -> 404 -> act (porting guide 5.1).
func authed(fn http.HandlerFunc) http.HandlerFunc {
return auth.AuthMiddleware(http.HandlerFunc(fn)).ServeHTTP
}
// admin wraps a route with auth + admin-only (403 unless ADMIN).
// AuthMiddleware runs first (attaches the session), then RequireAdmin checks it.
func admin(fn http.HandlerFunc) http.HandlerFunc {
return auth.AuthMiddleware(auth.RequireAdmin(http.HandlerFunc(fn))).ServeHTTP
}
// port returns the listen port from PORT env, defaulting to 8080.
func port() string {
if p := os.Getenv("PORT"); p != "" {
return p
}
return "8080"
}
+68
View File
@@ -0,0 +1,68 @@
package http
import (
"encoding/json"
"net/http"
"net/http/cookiejar"
"testing"
)
func TestRevocationAndForeignRelations(t *testing.T) {
srv := testServer(t)
newClient := func() apiClient { jar, _ := cookiejar.New(nil); return apiClient{t, &http.Client{Jar: jar}, srv.URL} }
a, b := newClient(), newClient()
account := func(c apiClient, email string) string {
body := map[string]any{"email": email, "password": "test-password"}
c.request("POST", "/api/signup", body, 201)
c.request("POST", "/api/auth/login", body, 200)
// Review CR-03: the id comes from the authenticated session, not the
// uniform signup body.
return meID(c)
}
account(a, "owner@computer.test")
bid := account(b, "recipient@computer.test")
tag := a.request("POST", "/api/tags", map[string]any{"name": "Shared"}, 201)["id"].(string)
id := a.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "tagIds": []string{tag}, "dueDate": "2026-10-07"}, 201)["id"].(string)
a.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{bid}}, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 200)
b.request("DELETE", "/api/bookmarks/"+id+"/share/"+bid, nil, 404)
a.request("DELETE", "/api/bookmarks/"+id+"/share/"+bid, nil, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 404)
a.request("PUT", "/api/tags/"+tag, map[string]any{"shareUserIds": []string{bid}}, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 200)
a.request("DELETE", "/api/tags/"+tag+"/share/"+bid, nil, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 404)
// Unsharing a tag must remove every discovery surface, not just the
// detail path (review recheck): list and search share the single
// BookmarkVisible predicate, so prove it holds end-to-end.
for _, q := range []string{"", "?search=127.0.0.1"} {
var list []map[string]any
res, e := b.c.Get(srv.URL + "/api/bookmarks" + q)
if e != nil {
t.Fatal(e)
}
json.NewDecoder(res.Body).Decode(&list)
res.Body.Close()
for _, item := range list {
if item["id"] == id {
t.Fatalf("revoked bookmark still visible in list %q", q)
}
}
}
a.request("POST", "/api/shared-dates", map[string]any{"date": "2026-10-07", "recipientIds": []string{bid}}, 201)
response, e := a.c.Get(srv.URL + "/api/shared-dates")
if e != nil {
t.Fatal(e)
}
var dates []map[string]any
if e = json.NewDecoder(response.Body).Decode(&dates); e != nil {
t.Fatal(e)
}
response.Body.Close()
b.request("GET", "/api/bookmarks/"+id, nil, 200)
a.request("DELETE", "/api/shared-dates/"+dates[0]["id"].(string), nil, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 404)
foreign := b.request("POST", "/api/kanban/columns", map[string]any{"label": "Private"}, 201)["id"].(string)
putBookmark(a, id, map[string]any{"kanbanColumnId": foreign}, 400)
putBookmark(a, id, map[string]any{"dueDate": map[string]any{}}, 400)
}
+59
View File
@@ -0,0 +1,59 @@
package kanban
import (
"context"
"database/sql"
"github.com/andrew/go-computer/internal/db"
)
var DefaultColumns = []struct{ Label, Color, Key string }{{"Inbox", "#64748b", "INBOX"}, {"To Do", "#3b82f6", "TODO"}, {"In Progress", "#f59e0b", "IN_PROGRESS"}, {"Done", "#22c55e", "DONE"}}
// GetColumns seeds the default board on first use. The common case (columns
// already exist) is a plain read — the user-row lock and seeding transaction
// are only taken when the count is zero, and the count is re-checked inside
// the transaction so concurrent first loads cannot double-seed.
func GetColumns(ctx context.Context, d *db.DB, userID string) ([]db.Object, error) {
var existing int
if err := d.QueryRowContext(ctx, `SELECT count(*) FROM "KanbanColumn" WHERE "userId"=$1`, userID).Scan(&existing); err != nil {
return nil, err
}
if existing == 0 {
err := db.Transaction(ctx, d, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT id FROM "User" WHERE id=$1 FOR UPDATE`, userID); e != nil {
return e
}
var n int
if e := tx.QueryRowContext(ctx, `SELECT count(*) FROM "KanbanColumn" WHERE "userId"=$1`, userID).Scan(&n); e != nil {
return e
}
if n == 0 {
for i, c := range DefaultColumns {
if _, e := db.Insert(ctx, tx, "KanbanColumn", db.Object{"id": db.NewID(), "userId": userID, "position": i, "label": c.Label, "color": c.Color, "key": c.Key}); e != nil {
return e
}
}
}
return nil
})
if err != nil {
return nil, err
}
}
return db.Many(ctx, d, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, userID)
}
func ResolveColumnID(b db.Object, columns []db.Object) string {
for _, c := range columns {
if b["kanbanColumnId"] == c["id"] {
return c["id"].(string)
}
}
for _, c := range columns {
if c["key"] != nil && c["key"] == b["kanbanStatus"] {
return c["id"].(string)
}
}
if len(columns) > 0 {
return columns[0]["id"].(string)
}
return ""
}
+77
View File
@@ -0,0 +1,77 @@
package markdown
import (
"fmt"
"github.com/andrew/go-computer/internal/db"
"net/http"
"regexp"
"strings"
)
var slugRE = regexp.MustCompile(`[^a-z0-9]+`)
func Slugify(s string) string {
s = strings.Trim(slugRE.ReplaceAllString(strings.ToLower(s), "-"), "-")
if len(s) > 60 {
s = s[:60]
}
if s == "" {
s = "untitled"
}
return s
}
func text(b db.Object, k string) string { s, _ := b[k].(string); return s }
func BookmarkDocument(b db.Object) string {
title := text(b, "title")
if title == "" {
title = text(b, "url")
}
out := "# " + title + "\n\n- URL: " + text(b, "url") + "\n"
if s := text(b, "dueDate"); len(s) >= 10 {
out += "- Date: " + s[:10] + "\n"
}
tags := []string{}
if a, ok := b["tags"].([]any); ok {
for _, x := range a {
bt, _ := x.(map[string]any)
t, _ := bt["tag"].(map[string]any)
tags = append(tags, text(t, "name"))
}
}
if len(tags) > 0 {
out += "- Tags: " + strings.Join(tags, ", ") + "\n"
}
return out + "\n" + strings.TrimSpace(text(b, "notes")) + "\n"
}
func PageDocument(p db.Object) string {
out := "# " + text(p, "title") + "\n\n" + strings.TrimSpace(text(p, "content")) + "\n"
if a, ok := p["bookmarks"].([]any); ok && len(a) > 0 {
out += "\n## Links\n"
for _, x := range a {
pb, _ := x.(map[string]any)
b, _ := pb["bookmark"].(map[string]any)
title := text(b, "title")
if title == "" {
title = text(b, "url")
}
title = strings.NewReplacer("[", "", "]", "", "\n", " ").Replace(title)
desc := strings.Join(strings.Fields(text(b, "description")), " ")
rs := []rune(desc)
if len(rs) > 160 {
desc = string(rs[:160])
}
out += "- [" + title + "](" + text(b, "url") + ")"
if desc != "" {
out += " — " + desc
}
out += "\n"
}
}
return out + "\n<!-- exported from computer " + text(p, "updatedAt") + " -->\n"
}
func Send(w http.ResponseWriter, body, name string) {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.md"`, Slugify(name)))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(body))
}
+196
View File
@@ -0,0 +1,196 @@
package models
// KanbanStatus is the legacy enum. Porting guide 2.1.
type KanbanStatus string
const (
Inbox KanbanStatus = "INBOX"
Todo KanbanStatus = "TODO"
InProgress KanbanStatus = "IN_PROGRESS"
Done KanbanStatus = "DONE"
Archived KanbanStatus = "ARCHIVED"
)
// User. Porting guide 2.2.
type User struct {
ID string
Name *string
Email *string
EmailVerified *string
Image *string
Password *string
Role string // USER | ADMIN
TokenVersion int // embedded in cookie JWTs; bumping revokes them
CreatedAt string
UpdatedAt string
}
// Account, Session, VerificationToken are auth-library tables. Porting guide 2.3.
// Session and VerificationToken are unused (sessions are JWT-based).
// Account is only needed if keeping OIDC.
type Account struct {
ID string
UserID string
Type string
Provider string
ProviderAccountID string
RefreshToken *string
AccessToken *string
ExpiresAt *int
TokenType *string
Scope *string
IDToken *string
SessionState *string
}
// Bookmark is the core object: one saved URL. Porting guide 2.4.
type Bookmark struct {
ID string
URL string
Title *string
Description *string
Favicon *string
OgImage *string
OgTitle *string
OgDescription *string
Notes *string
DueDate *string
AlertAt *string
AlertSent bool
KanbanStatus KanbanStatus
KanbanColumnID *string
ImapWatchEnabled bool
ImapQuery *string
IsPublic bool
OwnerID string
ContactID *string
CreatedAt string
UpdatedAt string
}
// Tag. Porting guide 2.5.
type Tag struct {
ID string
Name string
Color string
OwnerID string
CreatedAt string
UpdatedAt string
}
// BookmarkTag join. Porting guide 2.6.
type BookmarkTag struct {
BookmarkID string
TagID string
}
// BookmarkShare. Porting guide 2.7.
type BookmarkShare struct {
BookmarkID string
UserID string
CreatedAt string
}
// TagShare. Porting guide 2.8.
type TagShare struct {
TagID string
UserID string
CreatedAt string
}
// Contact, synced from Nextcloud CardDAV. Porting guide 2.9.
type Contact struct {
ID string
UID string
DisplayName string
Email *string
Phone *string
CardDavURL *string
UserID string
CreatedAt string
UpdatedAt string
}
// ImapConfig, one per user; password is encrypted. Porting guide 2.10.
type ImapConfig struct {
ID string
Host string
Port int
TLS bool
Username string
Password string // encrypted
Folder string
UserID string
}
// KanbanColumn, user-editable board column. Porting guide 2.11.
type KanbanColumn struct {
ID string
Label string
Color string
Position int
Key *string // INBOX/TODO/IN_PROGRESS/DONE for defaults, null for custom
UserID string
CreatedAt string
}
// Page, a Markdown workspace. Porting guide 2.12.
type Page struct {
ID string
Title string
Content string
Icon *string
Pinned bool
Position int
OwnerID string
CreatedAt string
UpdatedAt string
}
// PageBookmark links a bookmark onto a page. Porting guide 2.13.
type PageBookmark struct {
PageID string
BookmarkID string
Position int
CreatedAt string
}
// SignalSource, a watcher attached to a bookmark. Porting guide 2.14.
type SignalSource struct {
ID string
Type string
Config string // JSON, default "{}"
Enabled bool
LastCheckedAt *string
LastStatus *string
BookmarkID string
OwnerID string
CreatedAt string
UpdatedAt string
}
// Signal, one outside event about a bookmark. Porting guide 2.15.
type Signal struct {
ID string
SourceType string
Title string
Summary *string
URL *string
ExternalID *string
OccurredAt string
Read bool
SourceID *string
BookmarkID string
OwnerID string
CreatedAt string
}
// SharedDate. Porting guide 2.16.
type SharedDate struct {
ID string
Date string
Note *string
CreatorID string
RecipientID string
CreatedAt string
}
+212
View File
@@ -0,0 +1,212 @@
// Package netguard blocks server-initiated connections to non-public
// destinations. It backs URL previews, CardDAV sync and IMAP connections so a
// user-supplied host can never point the server at another user's internal
// service, the Docker database, or cloud metadata endpoints.
package netguard
import (
"context"
"errors"
"net"
"os"
"strconv"
"time"
)
// AllowPrivate reports whether range checks are disabled via
// INTEGRATIONS_ALLOW_PRIVATE=1. Tests set it for loopback fake servers;
// operators can set it for deliberate LAN deployments (home Nextcloud or
// mail on private addresses). It is off in the production Compose stack.
func AllowPrivate() bool { return os.Getenv("INTEGRATIONS_ALLOW_PRIVATE") == "1" }
// extraBlocked covers ranges net.IP's helpers miss. IPv4: "this network"
// (0.0.0.0/8), RFC 6598 CGNAT (100.64.0.0/10 — used by Tailscale, GKE
// internal LBs, some Docker setups), IETF protocol assignments
// (192.0.0.0/24), TEST-NET-1/2/3 documentation space (192.0.2.0/24,
// 198.51.100.0/24, 203.0.113.0/24), deprecated 6to4 relay anycast
// (192.88.99.0/24), benchmark space (198.18.0.0/15), and reserved/limited
// broadcast space (240.0.0.0/4, which includes 255.255.255.255).
// IPv6: discard-only (100::/64), benchmarking (2001:2::/48), ORCHID/ORCHIDv2
// (2001:10::/28, 2001:20::/28), documentation (2001:db8::/32, 3fff::/20),
// SRv6 SIDs (5f00::/16), deprecated 6bone (3ffe::/16), and site-local
// fec0::/10. The transition prefixes with embedded IPv4 (6to4, Teredo,
// IPv4-compatible/translated, NAT64) are handled explicitly below.
var extraBlocked = func() []net.IPNet {
cidrs := []string{
"0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24",
"192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24",
"240.0.0.0/4",
"100::/64", "2001:2::/48", "2001:10::/28", "2001:20::/28",
"2001:db8::/32", "3ffe::/16", "3fff::/20", "5f00::/16", "fec0::/10",
}
nets := make([]net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, n, err := net.ParseCIDR(cidr)
if err != nil {
panic("netguard: bad CIDR " + cidr)
}
nets = append(nets, *n)
}
return nets
}()
// parsePrefix is the panic-on-programmer-error helper for the fixed policy
// prefixes below.
func parsePrefix(cidr string) net.IPNet {
_, n, err := net.ParseCIDR(cidr)
if err != nil {
panic("netguard: bad CIDR " + cidr)
}
return *n
}
// Transition prefixes that embed an IPv4 destination in a different place
// than the well-known NAT64 layout. They are the "rare IPv6 tricks" from the
// review: without these checks an IPv6 literal can smuggle a loopback or
// RFC 1918 destination past the range guard.
var (
// RFC 6052 well-known NAT64: low 32 bits are the IPv4 destination.
nat64Prefix = parsePrefix("64:ff9b::/96")
// RFC 8215 local-use NAT64: never a public destination.
nat64LocalPrefix = parsePrefix("64:ff9b:1::/48")
// RFC 3056 6to4: bits 16..47 are the embedded IPv4 destination.
sixToFourPrefix = parsePrefix("2002::/16")
// RFC 4380 Teredo: block the whole prefix. Its client address is
// obfuscated and the route depends on a relay, so judging an embedded
// value is both unreliable and unnecessary for public web/mail targets.
teredoPrefix = parsePrefix("2001::/32")
// Deprecated IPv4-compatible (::/96) and IPv4-translated
// (::ffff:0:0:0/96) addresses. These are not IPv4-mapped addresses (those
// have To4() != nil and are judged directly above).
ipv4CompatiblePrefix = parsePrefix("::/96")
ipv4TranslatedPrefix = parsePrefix("::ffff:0:0:0/96")
)
func publicIPv4(ip net.IP) bool {
v4 := ip.To4()
if v4 == nil || !v4.IsGlobalUnicast() || v4.IsPrivate() || v4.IsLoopback() || v4.IsLinkLocalUnicast() {
return false
}
for _, n := range extraBlocked {
if n.Contains(v4) {
return false
}
}
return true
}
// Destination policy (review follow-up, stated explicitly): allowed targets
// are globally routable unicast addresses only. Blocked: private (RFC 1918,
// ULA fc00::/7), loopback, link-local, CGNAT, benchmark, IETF protocol and
// documentation ranges, unspecified/broadcast/multicast, IPv4-mapped
// addresses whose embedded IPv4 is non-public, and transition addresses
// (6to4/NAT64) whose embedded IPv4 is non-public. Teredo,
// IPv4-compatible/translated and local-use NAT64 prefixes are blocked
// wholesale. INTEGRATIONS_ALLOW_PRIVATE=1 (fixtures and deliberate LAN
// deployments) is the only relaxation.
func IsPublic(ip net.IP) bool {
if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if v4 := ip.To4(); v4 != nil {
return publicIPv4(v4)
}
v6 := ip.To16()
if v6 == nil {
return false
}
if ipv4CompatiblePrefix.Contains(v6) || ipv4TranslatedPrefix.Contains(v6) {
return false
}
if sixToFourPrefix.Contains(v6) {
return publicIPv4(net.IPv4(v6[2], v6[3], v6[4], v6[5]))
}
if teredoPrefix.Contains(v6) || nat64LocalPrefix.Contains(v6) {
return false
}
if nat64Prefix.Contains(v6) {
return publicIPv4(net.IPv4(v6[12], v6[13], v6[14], v6[15]))
}
for _, n := range extraBlocked {
if n.Contains(v6) {
return false
}
}
return true
}
var errBlocked = errors.New("netguard: private destination blocked")
var errBlockedPort = errors.New("netguard: destination port blocked")
// DialContextPorts is DialContext with an allowlist of destination ports.
// Callers that fetch user-supplied URLs use it to keep outbound traffic on
// the protocol's normal ports: a preview has no business dialing SSH or a
// database port on a public host. Fixtures and deliberate LAN deployments
// can bypass it by calling DialContext directly when AllowPrivate is set.
func DialContextPorts(ctx context.Context, network, address string, allowed ...int) (net.Conn, error) {
_, rawPort, e := net.SplitHostPort(address)
if e != nil {
return nil, e
}
port, e := strconv.Atoi(rawPort)
if e != nil {
return nil, errBlockedPort
}
ok := false
for _, p := range allowed {
if port == p {
ok = true
break
}
}
if !ok {
return nil, errBlockedPort
}
return DialContext(ctx, network, address)
}
// dialMaxAttempts bounds the fallback across already-validated addresses
// (review follow-up): the first A/AAAA record being unreachable does not make
// the host unreachable, but we never chase an unbounded list — and there is
// deliberately no second DNS lookup anywhere in this path.
const dialMaxAttempts = 3
// DialContext resolves the host, rejects non-public destinations, then dials
// the checked IPs. Check and dial use the same lookup result, so there is no
// DNS-rebinding window between them. Every resolved address must pass the
// range check (a host that mixes public and private records is refused
// outright), and the dial then falls back among the validated addresses
// instead of failing on the first unreachable one.
func DialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, e := net.SplitHostPort(address)
if e != nil {
return nil, e
}
ips, e := net.DefaultResolver.LookupIPAddr(ctx, host)
if e != nil {
return nil, e
}
if len(ips) == 0 {
return nil, errors.New("netguard: unresolved address")
}
if !AllowPrivate() {
for _, a := range ips {
if !IsPublic(a.IP) {
return nil, errBlocked
}
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
var lastErr error
for i, a := range ips {
if i >= dialMaxAttempts {
break
}
var conn net.Conn
conn, lastErr = dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
if lastErr == nil {
return conn, nil
}
}
return nil, lastErr
}
+113
View File
@@ -0,0 +1,113 @@
package netguard
import (
"context"
"fmt"
"net"
"testing"
"time"
)
func TestIsPublic(t *testing.T) {
public := []string{
"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888",
// NAT64 and 6to4 wrapping a PUBLIC embedded IPv4 address a real
// public destination on IPv6-only networks — allowed by policy.
"64:ff9b::8.8.8.8", "2002:808:808::",
}
blocked := []string{
"127.0.0.1", "::1", // loopback
"10.1.2.3", "172.16.0.1", "192.168.1.1", "fc00::1", "fd12::1", "fec0::1", // RFC1918 + ULA + site-local
"169.254.169.254", "fe80::1", // link-local / cloud metadata
"100.64.0.1", "100.127.255.254", // RFC 6598 CGNAT
"198.18.0.1", "198.19.255.255", // benchmark
"192.0.0.1", // IETF protocol assignments
"192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1", "3fff::1", // documentation space
"0.1.2.3", "240.0.0.1", "255.255.255.255", // this-network / reserved / broadcast
"0.0.0.0", "::", "224.0.0.1", "ff02::1", // unspecified / multicast
"::ffff:10.0.0.1", "::ffff:127.0.0.1", // IPv4-mapped private/loopback
"64:ff9b::7f00:1", "64:ff9b::a00:1", "64:ff9b::a9fe:a9fe", // NAT64-embedded loopback / private / metadata
"64:ff9b:1::8.8.8.8", // local-use NAT64 is never public
"2002:7f00:1::", "2002:a00:1::", "2002:a9fe:a9fe::", // 6to4-embedded loopback / private / metadata
"2001:0:0:0:0:0:7f00:1", "2001:0:4136:e378:8000:63bf:3fff:fdd2", // Teredo
"::127.0.0.1", "::10.0.0.1", "::ffff:0:7f00:1", "::ffff:0:10.0.0.1", // IPv4-compatible / translated
"100::1", "2001:2::1", "2001:10::1", "2001:20::1", "5f00::1", "3ffe::1", // discard, benchmark, ORCHID, SRv6, 6bone
}
for _, s := range public {
if !IsPublic(net.ParseIP(s)) {
t.Errorf("IsPublic(%s) = false, want true", s)
}
}
for _, s := range blocked {
if IsPublic(net.ParseIP(s)) {
t.Errorf("IsPublic(%s) = true, want false", s)
}
}
}
func TestAllowPrivateEnv(t *testing.T) {
if AllowPrivate() {
t.Fatal("AllowPrivate must default to false")
}
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
if !AllowPrivate() {
t.Fatal("AllowPrivate must honor INTEGRATIONS_ALLOW_PRIVATE=1")
}
}
func TestDialContextBlocksLoopbackByDefault(t *testing.T) {
if _, e := DialContext(context.Background(), "tcp", "127.0.0.1:5432"); e == nil {
t.Fatal("loopback destination accepted")
}
}
func TestDialContextPortsRejectsUnusualPorts(t *testing.T) {
// Port policy is checked before DNS, so this never dials.
if _, e := DialContextPorts(context.Background(), "tcp", "8.8.8.8:22", 80, 443); e == nil {
t.Fatal("SSH port accepted for a URL preview")
}
if _, e := DialContextPorts(context.Background(), "tcp", "8.8.8.8:not-a-port", 80, 443); e == nil {
t.Fatal("non-numeric port accepted")
}
}
// The NAT64 well-known prefix must not smuggle a non-public IPv4 destination
// past the guard (review follow-up: comprehensive special-range coverage).
func TestDialContextBlocksNAT64WrappedLoopback(t *testing.T) {
if _, e := DialContext(context.Background(), "tcp", "[64:ff9b::7f00:1]:80"); e == nil {
t.Fatal("NAT64-wrapped loopback destination accepted")
}
if _, e := DialContext(context.Background(), "tcp", "[64:ff9b::a9fe:a9fe]:80"); e == nil {
t.Fatal("NAT64-wrapped metadata destination accepted")
}
}
// Bounded fallback among already-validated addresses (review follow-up): a
// host that resolves to several addresses connects even when the first
// record is unreachable. "localhost" resolves to ::1 and/or 127.0.0.1
// depending on the resolver; the listener binds only IPv4 loopback, so on
// dual-stack machines the ::1 attempt must fail over to 127.0.0.1.
func TestDialContextFallsBackAmongValidatedAddresses(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
ln, e := net.Listen("tcp4", "127.0.0.1:0")
if e != nil {
t.Skipf("no IPv4 loopback listener possible: %v", e)
}
defer ln.Close()
go func() {
for {
c, e := ln.Accept()
if e != nil {
return
}
c.Close()
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, e := DialContext(ctx, "tcp", fmt.Sprintf("localhost:%d", ln.Addr().(*net.TCPAddr).Port))
if e != nil {
t.Fatalf("fallback dial to localhost failed: %v", e)
}
conn.Close()
}
+73
View File
@@ -0,0 +1,73 @@
package oidc
// Authelia OIDC configuration (STUB-AWARE). Porting guide 3.8.
//
// Authelia sign-in is only enabled when all three env vars hold real values:
// OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET
//
// Empty values or obvious placeholders (REPLACE_ME, example.com, changeme, ...)
// are treated as "not connected": the Authelia provider is not registered, the
// login button is shown disabled, and email/password login keeps working.
//
// See docs/AUTHELIA.md for the full connection guide.
import (
"os"
"regexp"
"strings"
)
var placeholderPatterns = []*regexp.Regexp{
regexp.MustCompile(`replace[_-]?me`),
regexp.MustCompile(`example\.(com|org|net)`),
regexp.MustCompile(`changeme`),
regexp.MustCompile(`placeholder`),
regexp.MustCompile(`^<.*>$`),
regexp.MustCompile(`^your[_-]`),
regexp.MustCompile(`^todo$`),
}
func isRealValue(value string) bool {
v := strings.ToLower(strings.TrimSpace(value))
if v == "" {
return false
}
for _, p := range placeholderPatterns {
if p.MatchString(v) {
return false
}
}
return true
}
// OidcStatus mirrors lib/oidc-config.ts. Enabled only when all three env
// values are real (non-placeholder). Never exposes the secret.
type OidcStatus struct {
Enabled bool
IssuerSet bool
ClientIDSet bool
ClientSecretSet bool
Issuer *string
}
// Status returns the current OIDC configuration state.
func Status() OidcStatus {
issuerSet := isRealValue(os.Getenv("OIDC_ISSUER"))
clientIdSet := isRealValue(os.Getenv("OIDC_CLIENT_ID"))
clientSecretSet := isRealValue(os.Getenv("OIDC_CLIENT_SECRET"))
var issuer *string
if issuerSet {
v := strings.TrimSpace(os.Getenv("OIDC_ISSUER"))
issuer = &v
}
return OidcStatus{
Enabled: issuerSet && clientIdSet && clientSecretSet,
IssuerSet: issuerSet,
ClientIDSet: clientIdSet,
ClientSecretSet: clientSecretSet,
Issuer: issuer,
}
}
// CallbackPath is where Authelia sends the OIDC code back.
const CallbackPath = "/api/auth/callback/authelia"
+96
View File
@@ -0,0 +1,96 @@
package oidc
// Provider is the Authelia OIDC sign-in provider.
//
// Porting guide 3.1: the Authelia provider is registered only when
// getOidcStatus().enabled. Scope "openid profile email". Profile mapping:
// id=sub, name = name ?? preferred_username ?? sub, email, image = picture.
// It links to an existing account with the same email.
//
// STUB: the full OIDC code flow (authorization code exchange, token endpoint,
// userinfo) is NOT implemented in Go — it will be driven by the client, which
// runs NextAuth and handles the provider itself. These handlers exist so the
// route table matches the original app and the Settings/login pages can read
// the status. They return 501 until the client-side flow is wired.
import (
"encoding/json"
"net/http"
)
// HandleStatus returns the current OIDC configuration state. This is what the
// Settings page and the login page read to decide whether to show the
// "Sign in with Authelia" button.
//
// GET /api/auth/oidc/status -> {enabled, issuerSet, clientIdSet, clientSecretSet, issuer}
func HandleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
s := Status()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"enabled": s.Enabled,
"issuerSet": s.IssuerSet,
"clientIdSet": s.ClientIDSet,
"clientSecretSet": s.ClientSecretSet,
"issuer": s.Issuer,
})
}
// HandleLogin starts the OIDC authorization-code flow. STUB: returns 501 until
// the client drives the flow via NextAuth.
//
// GET /api/auth/oidc/login
func HandleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "oidc not implemented",
"detail": "Authelia OIDC is driven by the client (NextAuth); this handler is a stub",
})
}
// HandleCallback is the Authelia OIDC callback. STUB: returns 501 until the
// client drives the flow via NextAuth.
//
// GET /api/auth/callback/authelia
func HandleCallback(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "oidc not implemented",
"detail": "Authelia OIDC is driven by the client (NextAuth); this handler is a stub",
})
}
// Profile maps an Authelia userinfo response to a user identity. Porting guide
// 3.1: id=sub, name = name ?? preferred_username ?? sub, email, image = picture.
type Profile struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Picture string `json:"picture"`
}
// MapProfile converts a raw Authelia profile into the identity fields.
func MapProfile(p Profile) (id, name, email, image string) {
id = p.Sub
if p.Name != "" {
name = p.Name
} else if p.PreferredUsername != "" {
name = p.PreferredUsername
} else {
name = p.Sub
}
email = p.Email
image = p.Picture
return
}
+267
View File
@@ -0,0 +1,267 @@
package preview
import (
"context"
"fmt"
"github.com/andrew/go-computer/internal/netguard"
"golang.org/x/net/html"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type Preview struct {
Title *string `json:"title"`
Description *string `json:"description"`
Favicon *string `json:"favicon"`
OgImage *string `json:"ogImage"`
OgTitle *string `json:"ogTitle"`
OgDescription *string `json:"ogDescription"`
}
// DNS is resolved and checked on each connection, including redirects, so
// URL previews cannot reach another user's internal service or Docker DB.
// The shared range logic lives in netguard (also used by CardDAV and IMAP).
// Preview fetches are additionally pinned to the normal web ports: a public
// host can still expose SSH, SMTP or a database, and a preview has no reason
// to touch them. The fixture/LAN escape hatch keeps loopback test servers
// (and deliberate private deployments) working on arbitrary ports.
func publicDial(ctx context.Context, network, address string) (net.Conn, error) {
if netguard.AllowPrivate() {
return netguard.DialContext(ctx, network, address)
}
return netguard.DialContextPorts(ctx, network, address, 80, 443)
}
// previewClient is reused for all preview fetches (review PERF-1): a new
// Transport per call leaked idle connections because IdleConnTimeout
// defaulted to 0. A single client with bounded idle pool and timeout keeps
// the connection count and file descriptors under control.
var previewClient = &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{
DialContext: publicDial,
IdleConnTimeout: 30 * time.Second,
MaxIdleConns: 32,
MaxIdleConnsPerHost: 8,
},
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) > 5 {
return fmt.Errorf("too many redirects")
}
if r.URL.Scheme != "http" && r.URL.Scheme != "https" {
return fmt.Errorf("invalid scheme")
}
return nil
},
}
// Shared preview-work budget (review CR-06). EVERY entry point that fetches
// preview metadata — the dedicated /api/preview route, bookmark creation and
// URL-changing updates — reserves against this one budget, so mutations can
// no longer bypass the preview route's throttle and generate unbounded
// outbound work. Two layers:
// - per-identity rate: at most keyLimit reservations per keyWindow;
// - global concurrency: at most maxConcurrent fetches in flight.
//
// Exhaustion returns an empty Preview immediately instead of queueing:
// previews are decorative, and a bookmark save must never be coupled to
// preview availability (the row persists without og metadata).
const (
keyLimit = 20
keyWindow = time.Minute
maxConcurrent = 8
)
type rateWindow struct {
count int
reset time.Time
}
type budgetStore struct {
mu sync.Mutex
inFlight int
keys map[string]*rateWindow
}
var sharedBudget = &budgetStore{keys: map[string]*rateWindow{}}
// Reserve claims one preview-work slot for key (the requesting identity).
// The returned release function MUST be called when the fetch finishes.
// ok is false when either budget layer is exhausted.
func Reserve(key string) (release func(), ok bool) {
if key == "" {
key = "anonymous"
}
sharedBudget.mu.Lock()
defer sharedBudget.mu.Unlock()
if sharedBudget.inFlight >= maxConcurrent {
return nil, false
}
now := time.Now()
// Opportunistic prune keeps the map bounded by active identities.
if len(sharedBudget.keys) > 4096 {
for k, w := range sharedBudget.keys {
if now.After(w.reset) {
delete(sharedBudget.keys, k)
}
}
}
w := sharedBudget.keys[key]
if w == nil || now.After(w.reset) {
w = &rateWindow{reset: now.Add(keyWindow)}
sharedBudget.keys[key] = w
}
if w.count >= keyLimit {
return nil, false
}
w.count++
sharedBudget.inFlight++
return func() {
sharedBudget.mu.Lock()
sharedBudget.inFlight--
sharedBudget.mu.Unlock()
}, true
}
// FetchPreview resolves metadata for raw on behalf of key (the requesting
// identity, e.g. "user:<id>"). The caller's context bounds the fetch (review
// CORR-02): when the incoming request is cancelled — the user closed the tab
// or aborted the save — the outbound I/O and HTML parsing stop instead of
// running to the 8-second client timeout. An exhausted shared budget (review
// CR-06) yields an empty Preview without any outbound work.
func FetchPreview(ctx context.Context, key, raw string) Preview {
release, ok := Reserve(key)
if !ok {
return Preview{}
}
defer release()
u, e := url.Parse(raw)
if e != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
return Preview{}
}
req, e := http.NewRequestWithContext(ctx, "GET", raw, nil)
if e != nil {
return Preview{}
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; computer/1.0)")
resp, e := previewClient.Do(req)
if e != nil {
return Preview{}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return Preview{}
}
// Review CORR-01: resolve relative favicons/og:images against the final
// response URL, not the caller-supplied one — a redirect can change the
// host or base path and break every relative asset.
base := u
if resp.Request != nil && resp.Request.URL != nil {
base = resp.Request.URL
}
return ParseHTML(io.LimitReader(resp.Body, 1<<20), base)
}
// clipRunes keeps remote metadata bounded: a hostile or merely careless page
// can otherwise put a megabyte into a title/description that every bookmark
// list then carries around.
func clipRunes(s string, max int) string {
r := []rune(s)
if len(r) > max {
r = r[:max]
}
return string(r)
}
func ParseHTML(r io.Reader, base *url.URL) Preview {
out := Preview{}
z := html.NewTokenizer(r)
var title strings.Builder
inTitle := false
absolute := func(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
u, e := base.Parse(s)
if e != nil || (u.Scheme != "http" && u.Scheme != "https") {
return nil
}
s = clipRunes(u.String(), 2048)
return &s
}
for {
t := z.Next()
if t == html.ErrorToken {
break
}
tok := z.Token()
if t == html.TextToken && inTitle {
title.WriteString(tok.Data)
}
if t == html.EndTagToken && tok.Data == "title" {
inTitle = false
}
if t != html.StartTagToken && t != html.SelfClosingTagToken {
continue
}
if tok.Data == "title" {
inTitle = true
}
attrs := map[string]string{}
for _, a := range tok.Attr {
attrs[a.Key] = a.Val
}
if tok.Data == "meta" {
key := strings.ToLower(attrs["property"])
if key == "" {
key = strings.ToLower(attrs["name"])
}
v := strings.TrimSpace(attrs["content"])
if v != "" {
switch key {
case "og:title":
if out.OgTitle == nil {
c := clipRunes(v, 500)
out.OgTitle = &c
}
case "og:description":
if out.OgDescription == nil {
c := clipRunes(v, 2000)
out.OgDescription = &c
}
case "description":
if out.Description == nil {
c := clipRunes(v, 2000)
out.Description = &c
}
case "og:image":
if out.OgImage == nil {
out.OgImage = absolute(v)
}
}
}
}
if tok.Data == "link" && strings.Contains(strings.ToLower(attrs["rel"]), "icon") && out.Favicon == nil {
out.Favicon = absolute(attrs["href"])
}
}
if out.OgTitle != nil {
out.Title = out.OgTitle
} else if s := clipRunes(strings.TrimSpace(title.String()), 500); s != "" {
out.Title = &s
}
if out.OgDescription != nil {
out.Description = out.OgDescription
}
if out.Favicon == nil {
s := base.Scheme + "://" + base.Host + "/favicon.ico"
out.Favicon = &s
}
return out
}
+85
View File
@@ -0,0 +1,85 @@
package preview
import (
"context"
"fmt"
"net/url"
"strings"
"testing"
"time"
)
func TestHTMLMetadata(t *testing.T) {
u, _ := url.Parse("https://site.test/docs/page")
p := ParseHTML(strings.NewReader(`<META content='A &amp; B' property='og:title'><meta property="og:image" content="/cover.png"><link href="../icon.png" rel="shortcut icon">`), u)
if p.OgTitle == nil || *p.OgTitle != "A & B" || p.OgImage == nil || *p.OgImage != "https://site.test/cover.png" || p.Favicon == nil || *p.Favicon != "https://site.test/icon.png" {
t.Fatalf("metadata: %+v", p)
}
}
// Remote metadata is clipped so a hostile page cannot bloat every bookmark
// list response with a megabyte-long title or description.
func TestHTMLMetadataIsBounded(t *testing.T) {
u, _ := url.Parse("https://site.test/")
title := strings.Repeat("T", 1000)
desc := strings.Repeat("D", 5000)
p := ParseHTML(strings.NewReader(`<html><head><title>`+title+`</title><meta name="description" content="`+desc+`"></head></html>`), u)
if p.Title == nil || len([]rune(*p.Title)) != 500 {
t.Fatalf("title not clipped: %v", p.Title)
}
if p.Description == nil || len([]rune(*p.Description)) != 2000 {
t.Fatalf("description not clipped: %v", p.Description)
}
}
func TestPrivatePreviewIsBlocked(t *testing.T) {
if c, e := publicDial(context.Background(), "tcp", "127.0.0.1:5432"); e == nil {
c.Close()
t.Fatal("private destination accepted")
}
}
// Review CR-06: every preview entry point draws from ONE shared budget — a
// per-identity rate and a global concurrency cap. Exhaustion must deny
// immediately (callers then persist bookmarks without preview metadata)
// instead of queueing outbound work.
func TestSharedBudget(t *testing.T) {
stamp := time.Now().UnixNano()
key := fmt.Sprintf("user:rate-%d", stamp)
for i := 0; i < keyLimit; i++ {
release, ok := Reserve(key)
if !ok {
t.Fatalf("reservation %d within the per-key limit denied", i)
}
release()
}
if _, ok := Reserve(key); ok {
t.Fatal("per-key rate budget exceeded")
}
// An unrelated identity is unaffected by the exhausted key.
if release, ok := Reserve(key + "-other"); !ok {
t.Fatal("independent key denied")
} else {
release()
}
// Global concurrency: held (unreleased) slots cap all identities.
var releases []func()
for i := 0; i < maxConcurrent; i++ {
release, ok := Reserve(fmt.Sprintf("user:conc-%d-%d", stamp, i))
if !ok {
t.Fatalf("concurrency slot %d denied", i)
}
releases = append(releases, release)
}
if _, ok := Reserve(fmt.Sprintf("user:conc-overflow-%d", stamp)); ok {
t.Fatal("global concurrency cap not enforced")
}
for _, release := range releases {
release()
}
if release, ok := Reserve(fmt.Sprintf("user:conc-after-%d", stamp)); !ok {
t.Fatal("slot not returned after release")
} else {
release()
}
}
+36
View File
@@ -0,0 +1,36 @@
package scripts
import (
"context"
"fmt"
"github.com/andrew/go-computer/internal/db"
"golang.org/x/crypto/bcrypt"
"os"
"strings"
)
// Seed only creates the requested account; it never clears existing records.
func Seed(ctx context.Context, d *db.DB) error {
email, password := strings.ToLower(strings.TrimSpace(os.Getenv("SEED_EMAIL"))), os.Getenv("SEED_PASSWORD")
if email == "" || password == "" {
return fmt.Errorf("SEED_EMAIL and SEED_PASSWORD are required")
}
repo := db.NewUserRepo(d)
if _, e := repo.FindByEmail(ctx, email); e == nil {
return nil
}
hash, e := bcrypt.GenerateFromPassword([]byte(password), 12)
if e != nil {
return e
}
name := os.Getenv("SEED_NAME")
if name == "" {
name = "Admin"
}
role := "USER"
if db.IsAdminEmail(email) {
role = "ADMIN"
}
_, e = repo.Create(ctx, &db.NewUserInput{ID: db.NewID(), Name: name, Email: email, Password: string(hash), Role: role})
return e
}
+347
View File
@@ -0,0 +1,347 @@
package signals
import (
"context"
"errors"
"fmt"
"net"
"sync"
"testing"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend"
"github.com/emersion/go-imap/backend/memory"
imapserver "github.com/emersion/go-imap/server"
)
// validityBackend wraps the in-memory backend so tests can force the
// UIDVALIDITY the server reports (the stock memory backend hardcodes 1) and
// capture the SEARCH criteria every pass issues (review CR-15/CR-19
// verification).
type validityBackend struct {
backend.Backend
validity uint32
mu sync.Mutex
criteria []*imap.SearchCriteria
// failFetchAfter makes the fixture fail ListMessages calls after the
// given count, simulating a mailbox that dies partway through a pass.
failFetchAfter int
fetches int
}
func (b *validityBackend) Login(connInfo *imap.ConnInfo, username, password string) (backend.User, error) {
u, e := b.Backend.Login(connInfo, username, password)
if e != nil {
return nil, e
}
return &validityUser{User: u, be: b}, nil
}
func (b *validityBackend) captured() []*imap.SearchCriteria {
b.mu.Lock()
defer b.mu.Unlock()
return append([]*imap.SearchCriteria(nil), b.criteria...)
}
type validityUser struct {
backend.User
be *validityBackend
}
func (u *validityUser) GetMailbox(name string) (backend.Mailbox, error) {
m, e := u.User.GetMailbox(name)
if e != nil {
return nil, e
}
return &validityMailbox{Mailbox: m, be: u.be}, nil
}
type validityMailbox struct {
backend.Mailbox
be *validityBackend
}
func (m *validityMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) {
st, e := m.Mailbox.Status(items)
if e == nil && m.be.validity != 0 {
st.UidValidity = m.be.validity
}
return st, e
}
func (m *validityMailbox) SearchMessages(uid bool, c *imap.SearchCriteria) ([]uint32, error) {
if uid {
m.be.mu.Lock()
m.be.criteria = append(m.be.criteria, c)
m.be.mu.Unlock()
}
return m.Mailbox.SearchMessages(uid, c)
}
func (m *validityMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.FetchItem, ch chan<- *imap.Message) error {
m.be.mu.Lock()
m.be.fetches++
fail := m.be.failFetchAfter > 0 && m.be.fetches > m.be.failFetchAfter
m.be.mu.Unlock()
if fail {
close(ch)
return errors.New("fixture fetch failure")
}
return m.Mailbox.ListMessages(uid, seqset, items, ch)
}
// newTestMailbox stands up the in-memory IMAP fixture the integration suite
// uses, reseeded with n messages in INBOX (the backend ships one). The server
// enforces real protocol semantics, so SearchConnFrom exercises
// UidSearch/UidFetch against genuine UIDVALIDITY/UIDNEXT responses.
func newTestMailbox(t *testing.T, n int) (Mailbox, func()) {
m, _, cleanup := newTestMailboxValidity(t, n, 0)
return m, cleanup
}
// newTestMailboxValidity is newTestMailbox with a forced UIDVALIDITY (0 keeps
// the backend default) and access to the criteria capture.
func newTestMailboxValidity(t *testing.T, n int, validity uint32) (Mailbox, *validityBackend, func()) {
t.Helper()
mb := memory.New()
// Reach the fixture's own user/mailbox through a refused login, which is
// how the backend exposes them.
_, err := mb.Login(nil, "username", "bad")
if err == nil {
t.Fatal("fixture login unexpectedly succeeded")
}
user, err := mb.Login(nil, "username", "password")
if err != nil {
t.Fatal(err)
}
mbox, err := user.GetMailbox("INBOX")
if err != nil {
t.Fatal(err)
}
memMbox := mbox.(*memory.Mailbox)
memMbox.Messages = memMbox.Messages[:0]
for i := 0; i < n; i++ {
body := "From: sender@computer.test\r\n" +
"Subject: subject " + fmt.Sprint(i) + "\r\n" +
"Message-Id: <msg-" + fmt.Sprint(i) + "@computer.test>\r\n" +
"\r\n" + "body"
memMbox.Messages = append(memMbox.Messages, &memory.Message{
Uid: uint32(i + 1),
Date: time.Now(),
Size: uint32(len(body)),
Body: []byte(body),
})
}
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
be := &validityBackend{Backend: mb, validity: validity}
mail := imapserver.New(be)
mail.AllowInsecureAuth = true
go mail.Serve(listener)
host, port, _ := net.SplitHostPort(listener.Addr().String())
var p int
fmt.Sscanf(port, "%d", &p)
m := Mailbox{Host: host, Username: "username", Password: "password", Port: p, TLS: false, Folder: "INBOX"}
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1")
return m, be, func() { mail.Close() }
}
// TestTruncatedPassKeepsRemainingMatches pins review COR-1: when the
// per-pass cap truncates the matched UID list, the checkpoint must advance
// only to the last fetched UID — never to the mailbox high-water mark — or
// matches past the cap are dropped forever.
func TestTruncatedPassKeepsRemainingMatches(t *testing.T) {
// Connect dials loopback through netguard; the fixture flag unblocks it
// (same escape hatch the integration harness uses).
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
cap := searchBatch * maxIngestBatches
m, cleanup := newTestMailbox(t, cap+7)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c, e := Connect(ctx, m)
if e != nil {
t.Fatal(e)
}
defer c.Logout()
events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{})
if e != nil {
t.Fatal(e)
}
if len(events) != cap {
t.Fatalf("fetched %d events, want %d (per-pass cap)", len(events), cap)
}
if next.LastUID != uint32(cap) {
t.Fatalf("checkpoint advanced to %d, want %d (high-water shortcut must stay off after truncation)", next.LastUID, cap)
}
// A follow-up pass from the new checkpoint picks up every remaining match.
rest, final, e := SearchConnFrom(c, m, "", "subject", next)
if e != nil {
t.Fatal(e)
}
if len(rest) != 7 {
t.Fatalf("second pass fetched %d events, want 7", len(rest))
}
if final.LastUID != uint32(cap+7) {
t.Fatalf("final checkpoint %d, want %d", final.LastUID, cap+7)
}
}
// Review follow-up: a mailbox that fails after an earlier batch succeeded
// still returns the events already fetched. The caller persists them but must
// not advance the checkpoint past the failed batch.
func TestPartialFetchFailureReturnsEarlierEvents(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
m, be, cleanup := newTestMailboxValidity(t, searchBatch*2+5, 0)
defer cleanup()
be.mu.Lock()
be.failFetchAfter = 1
be.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c, e := Connect(ctx, m)
if e != nil {
t.Fatal(e)
}
defer c.Logout()
events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{})
if e == nil {
t.Fatal("fixture fetch failure was not reported")
}
if len(events) != searchBatch {
t.Fatalf("partial pass returned %d events, want the first batch (%d)", len(events), searchBatch)
}
if next.LastUID != searchBatch {
t.Fatalf("partial checkpoint %d, want %d (never past the failed batch)", next.LastUID, searchBatch)
}
}
func TestCheckpointHighWaterShortcutAppliesWhenComplete(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
// When the SEARCH considered every message (no truncation), the
// checkpoint may advance to the mailbox high-water mark.
m, cleanup := newTestMailbox(t, searchBatch+1)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c, e := Connect(ctx, m)
if e != nil {
t.Fatal(e)
}
defer c.Logout()
events, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{})
if e != nil {
t.Fatal(e)
}
if len(events) != searchBatch+1 {
t.Fatalf("fetched %d events, want %d", len(events), searchBatch+1)
}
if next.LastUID != uint32(searchBatch+1) {
t.Fatalf("checkpoint %d, want %d", next.LastUID, searchBatch+1)
}
}
// TestUIDValidityResetReingests pins review CR-15 with a fixture-backed
// ingestion pass: when UIDVALIDITY changes, the new UID space is scanned from
// zero — even when every new UID sits BELOW the old checkpoint's LastUID.
// The old behavior filtered against the stale high-water mark and skipped the
// whole new space while advancing the checkpoint past it.
func TestUIDValidityResetReingests(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
// New UID space: UIDs 1..20 under UIDVALIDITY 8.
m, _, cleanup := newTestMailboxValidity(t, 20, 8)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c, e := Connect(ctx, m)
if e != nil {
t.Fatal(e)
}
defer c.Logout()
// A checkpoint from the PREVIOUS UID space whose LastUID (100) is above
// every UID that now exists.
stale := Checkpoint{UIDValidity: 7, LastUID: 100}
events, next, e := SearchConnFrom(c, m, "", "subject", stale)
if e != nil {
t.Fatal(e)
}
if len(events) != 20 {
t.Fatalf("reset pass ingested %d events, want all 20 of the new UID space", len(events))
}
if next.UIDValidity != 8 || next.LastUID != 20 {
t.Fatalf("checkpoint after reset = %+v, want {UIDValidity:8 LastUID:20}", next)
}
// A subsequent pass from the reset checkpoint finds nothing new and does
// not regress the checkpoint.
again, final, e := SearchConnFrom(c, m, "", "subject", next)
if e != nil {
t.Fatal(e)
}
if len(again) != 0 {
t.Fatalf("follow-up pass ingested %d events, want 0", len(again))
}
if final.UIDValidity != 8 || final.LastUID != 20 {
t.Fatalf("follow-up checkpoint = %+v, want {UIDValidity:8 LastUID:20}", final)
}
}
// TestIncrementalPassConstrainsUIDSearch pins review CR-19: once a source is
// caught up, the server-side SEARCH carries a UID window derived from the
// normalized checkpoint instead of re-matching the whole history. The reset
// pass (CR-15) must NOT constrain — the whole new UID space is fair game.
func TestIncrementalPassConstrainsUIDSearch(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
m, be, cleanup := newTestMailboxValidity(t, 10, 5)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c, e := Connect(ctx, m)
if e != nil {
t.Fatal(e)
}
defer c.Logout()
// Pass 1: mismatched UIDVALIDITY forces a reset; no UID constraint.
if _, next, e := SearchConnFrom(c, m, "", "subject", Checkpoint{UIDValidity: 4, LastUID: 99}); e != nil {
t.Fatal(e)
} else if next.LastUID != 10 {
t.Fatalf("reset pass checkpoint %d, want 10", next.LastUID)
}
// Pass 2: caught up at UID 4 of validity 5 — only UIDs 5..10 ingest.
events, next2, e := SearchConnFrom(c, m, "", "subject", Checkpoint{UIDValidity: 5, LastUID: 4})
if e != nil {
t.Fatal(e)
}
if len(events) != 6 {
t.Fatalf("incremental pass ingested %d events, want 6", len(events))
}
if next2.LastUID != 10 {
t.Fatalf("incremental checkpoint %d, want 10", next2.LastUID)
}
got := be.captured()
if len(got) != 2 {
t.Fatalf("captured %d UID searches, want 2", len(got))
}
if got[0].Uid != nil {
t.Fatalf("reset pass constrained the SEARCH to %v; it must rescan the whole UID space", got[0].Uid)
}
if got[1].Uid == nil {
t.Fatal("incremental pass did not constrain the SEARCH by UID window")
}
if !got[1].Uid.Contains(5) || got[1].Uid.Contains(4) {
t.Fatalf("incremental SEARCH UID window %v does not start at 5", got[1].Uid)
}
}
+153
View File
@@ -0,0 +1,153 @@
package signals
import (
"context"
"github.com/andrew/go-computer/internal/db"
"log"
"sync"
"time"
)
const (
// pollConcurrency bounds simultaneous mailbox passes; each pass holds one
// IMAP login and a 45-second budget (review SCALE-01).
pollConcurrency = 3
perUserTimeout = 45 * time.Second
// maxBackoffShift caps failure backoff at 2^4 ticks (~80 minutes).
maxBackoffShift = 4
)
// Scheduler polls users with enabled signal sources. Review SCALE-01:
// bounded concurrency, per-user exponential backoff on consecutive failures,
// logged outcomes (counts only — never credentials or mail content), and a
// database-backed advisory lease so multiple app instances never poll the
// same user simultaneously. Database deduplication would hide duplicate
// records but not the duplicated external work the lease prevents.
type Scheduler struct {
mu sync.Mutex
failures map[string]int
tick int
}
func NewScheduler() *Scheduler { return &Scheduler{failures: map[string]int{}} }
// RunOnce performs a single scheduler pass over every user with enabled
// sources. Extracted from RunLoop so tests can drive one tick directly.
func RunOnce(ctx context.Context, d *db.DB) { NewScheduler().RunOnce(ctx, d) }
func (s *Scheduler) RunOnce(ctx context.Context, d *db.DB) {
users, e := db.Many(ctx, d, `SELECT jsonb_build_object('id',"ownerId") FROM "SignalSource" WHERE enabled GROUP BY "ownerId"`)
if e != nil {
log.Printf("watchers: query failed: %v", e)
return
}
s.mu.Lock()
s.tick++
tick := s.tick
s.mu.Unlock()
sem := make(chan struct{}, pollConcurrency)
var wg sync.WaitGroup
for _, u := range users {
if ctx.Err() != nil {
break
}
id, _ := u["id"].(string)
if id == "" || s.skipTick(id, tick) {
continue
}
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
s.checkUser(ctx, d, id)
}()
}
wg.Wait()
}
// skipTick applies per-user exponential backoff: after n consecutive failed
// passes the user is polled on every 2^n-th tick instead of every tick.
func (s *Scheduler) skipTick(id string, tick int) bool {
s.mu.Lock()
defer s.mu.Unlock()
f := s.failures[id]
if f == 0 {
return false
}
shift := min(f, maxBackoffShift)
return tick%(1<<shift) != 0
}
func (s *Scheduler) checkUser(ctx context.Context, d *db.DB, id string) {
// Database-backed lease held on a dedicated connection for the duration
// of the pass: a second poller instance skips this user instead of
// repeating the same external mailbox work.
conn, e := d.Conn(ctx)
if e != nil {
s.note(id, false)
log.Printf("watchers: lease connection for user %s failed: %v", id, e)
return
}
defer conn.Close()
var locked bool
if e := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, "signal-poll:"+id).Scan(&locked); e != nil {
s.note(id, false)
log.Printf("watchers: lease for user %s failed: %v", id, e)
return
}
if !locked {
return // another instance owns this user's pass
}
defer conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, "signal-poll:"+id)
job, cancel := context.WithTimeout(ctx, perUserTimeout)
defer cancel()
res, e := RunSignalChecks(job, d, id, "")
if e != nil {
s.note(id, false)
log.Printf("watchers: check failed for user %s: %v", id, e)
return
}
if res.Failed > 0 {
// Review CR-16: real integration failures (mailbox auth/connection,
// search, insert, checkpoint or status writes) must drive backoff —
// previously they were recorded per source only and the pass counted
// as scheduler success, retrying every tick forever.
s.note(id, false)
log.Printf("watchers: user %s: %d of %d sources failed", id, res.Failed, res.Sources)
return
}
s.note(id, true)
if res.Sources > 0 || res.Created > 0 {
log.Printf("watchers: user %s: %d sources checked, %d signals created", id, res.Sources, res.Created)
}
}
func (s *Scheduler) note(id string, ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
if ok {
delete(s.failures, id)
} else {
s.failures[id]++
}
}
// Browser alerts remain browser-only. Email watchers are checked independently
// of an open tab, with source/external-ID deduplication and a durable per-source
// UID checkpoint on every run. A slow pass delays the next one because the
// ticker drops missed ticks.
func RunLoop(ctx context.Context, d *db.DB) {
tick := time.NewTicker(5 * time.Minute)
defer tick.Stop()
s := NewScheduler()
s.RunOnce(ctx, d)
for {
select {
case <-ctx.Done():
return
case <-tick.C:
s.RunOnce(ctx, d)
}
}
}
+443
View File
@@ -0,0 +1,443 @@
package signals
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"github.com/andrew/go-computer/internal/config"
crypt "github.com/andrew/go-computer/internal/crypto"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/netguard"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"log"
"math"
"net"
"os"
"sort"
"strings"
"time"
)
type SignalInput struct {
ExternalID, Title, Summary string
OccurredAt time.Time
}
type Mailbox struct {
Host, Username, Password, Folder string
Port int
TLS bool
}
// AllowInsecure reports whether plaintext IMAP logins are permitted. Only
// explicit development/test fixtures may set INTEGRATIONS_ALLOW_INSECURE_IMAP;
// production configurations must always authenticate over verified TLS
// (review NET-02). Never set it in production Compose.
func AllowInsecure() bool { return os.Getenv("INTEGRATIONS_ALLOW_INSECURE_IMAP") == "1" }
// checkTransport refuses password login on a plaintext socket (review NET-02).
// Implicit TLS is the only production transport; there is no STARTTLS
// downgrade path.
func checkTransport(m Mailbox) error {
if !m.TLS && !AllowInsecure() {
return errors.New("signals: refusing plaintext IMAP connection; TLS is required")
}
return nil
}
// Connect dials through netguard, so user-supplied IMAP hosts cannot point
// the server at internal infrastructure. The 15-second deadline covers
// dial/TLS/login only and is cleared afterwards; later commands are bounded
// by the caller's context (AfterFunc termination), not by a stale absolute
// deadline.
func Connect(ctx context.Context, m Mailbox) (*client.Client, error) {
if e := checkTransport(m); e != nil {
return nil, e
}
if m.Port == 0 {
m.Port = 993
}
// Keep outbound mailbox traffic on the normal IMAP ports. The fixture
// flags cover loopback fakes (and deliberate LAN deployments) that listen
// on an ephemeral port; production has no reason to dial, say, an SSH or
// database port through the netguard escape hatch.
if !netguard.AllowPrivate() && !AllowInsecure() && m.Port != 993 && m.Port != 143 {
return nil, errors.New("signals: mailbox port must be 143 or 993")
}
if m.Folder == "" {
m.Folder = "INBOX"
}
conn, e := netguard.DialContext(ctx, "tcp", net.JoinHostPort(m.Host, fmt.Sprint(m.Port)))
if e != nil {
return nil, e
}
if e = conn.SetDeadline(time.Now().Add(15 * time.Second)); e != nil {
conn.Close()
return nil, e
}
if m.TLS {
t := tls.Client(conn, &tls.Config{ServerName: m.Host, MinVersion: tls.VersionTLS12})
if e = t.HandshakeContext(ctx); e != nil {
conn.Close()
return nil, e
}
conn = t
}
c, e := client.New(conn)
if e != nil {
conn.Close()
return nil, e
}
if e = c.Login(m.Username, m.Password); e != nil {
c.Terminate()
return nil, e
}
if e = conn.SetDeadline(time.Time{}); e != nil {
c.Terminate()
return nil, e
}
return c, nil
}
func Search(ctx context.Context, m Mailbox, from, subject string) ([]SignalInput, error) {
c, e := Connect(ctx, m)
if e != nil {
return nil, e
}
stop := context.AfterFunc(ctx, func() { c.Terminate() })
defer stop()
defer c.Logout()
return SearchConn(c, m, from, subject)
}
// Checkpoint is the durable per-source ingestion state (review SYNC-02):
// the folder UIDVALIDITY it was recorded against and the highest UID already
// considered. A zero value means "nothing ingested yet".
type Checkpoint struct{ UIDValidity, LastUID uint32 }
func LoadCheckpoint(ctx context.Context, q db.Queryer, sourceID string) (Checkpoint, error) {
var cp Checkpoint
var validity, last int64
e := q.QueryRowContext(ctx, `SELECT "uidValidity","lastUid" FROM "SignalCheckpoint" WHERE "sourceId"=$1`, sourceID).Scan(&validity, &last)
if errors.Is(e, sql.ErrNoRows) {
return Checkpoint{}, nil
}
if e != nil {
return Checkpoint{}, e
}
cp.UIDValidity, cp.LastUID = uint32(validity), uint32(last)
return cp, nil
}
// SaveCheckpoint persists cp. Callers must only advance a checkpoint after
// the batch's signals are durably handled. The update is monotonic (review
// COR-4): when uidValidity is unchanged, lastUid only advances via GREATEST,
// so a slower concurrent pass can never regress the checkpoint and force a
// re-scan of messages already ingested.
func SaveCheckpoint(ctx context.Context, q db.Queryer, sourceID string, cp Checkpoint) error {
_, e := q.ExecContext(ctx, `INSERT INTO "SignalCheckpoint"("sourceId","uidValidity","lastUid","updatedAt") VALUES($1,$2,$3,now()) ON CONFLICT("sourceId") DO UPDATE SET "uidValidity"=EXCLUDED."uidValidity","lastUid"=CASE WHEN "SignalCheckpoint"."uidValidity"=EXCLUDED."uidValidity" THEN GREATEST("SignalCheckpoint"."lastUid",EXCLUDED."lastUid") ELSE EXCLUDED."lastUid" END,"updatedAt"=now()`, sourceID, int64(cp.UIDValidity), int64(cp.LastUID))
return e
}
const (
// searchBatch bounds one UID FETCH round trip.
searchBatch = 50
// maxIngestBatches caps a single checkpointed pass at 2500 messages per
// source; a later pass continues from the persisted checkpoint.
maxIngestBatches = 50
)
// SearchConn runs one bounded search on an established connection and
// reports the most recent matches. It is preview semantics for interactive
// checks ("Check now", legacy mailbox watch): no checkpoint is consulted or
// advanced. Durable ingestion uses SearchConnFrom. An empty criteria would
// match the entire mailbox, so it yields nothing instead. On a mid-pass fetch
// failure it returns the matches already fetched alongside the error.
func SearchConn(c *client.Client, m Mailbox, from, subject string) ([]SignalInput, error) {
events, _, e := search(c, m, from, subject, nil)
return events, e
}
// SearchConnFrom processes only messages newer than cp and returns the
// checkpoint to persist after — and only after — the returned inputs are
// durably handled (review SYNC-02). A mid-pass fetch failure returns the
// events fetched so far; callers must persist them WITHOUT saving the
// checkpoint, so the failed batch is retried and deduplicated. Batches are processed until the source
// is caught up or the per-pass cap is reached. When the folder's UIDVALIDITY
// changed, the checkpoint resets and the mailbox is re-scanned; Message-ID
// deduplication keeps re-scans from duplicating signals that carry one.
func SearchConnFrom(c *client.Client, m Mailbox, from, subject string, cp Checkpoint) ([]SignalInput, Checkpoint, error) {
events, next, e := search(c, m, from, subject, &cp)
return events, next, e
}
func search(c *client.Client, m Mailbox, from, subject string, cp *Checkpoint) ([]SignalInput, Checkpoint, error) {
next := Checkpoint{}
if cp != nil {
next = *cp
}
if from == "" && subject == "" {
return []SignalInput{}, next, nil
}
folder := m.Folder
if folder == "" {
folder = "INBOX"
}
status, e := c.Select(folder, true)
if e != nil {
return nil, next, e
}
// Effective checkpoint (review CR-15): exactly one normalized checkpoint
// drives BOTH the UID filtering and the advancement below. When the
// folder's UIDVALIDITY changed, the previous UID space is meaningless —
// the effective checkpoint resets to zero so every UID in the new space
// is (re)ingested, and filtering must never consult the stale LastUID.
eff := Checkpoint{}
if cp != nil {
if cp.UIDValidity == status.UidValidity {
eff = *cp
}
next = eff
}
next.UIDValidity = status.UidValidity
criteria := imap.NewSearchCriteria()
if from != "" {
criteria.Header.Add("From", from)
}
if subject != "" {
criteria.Header.Add("Subject", subject)
}
// Review CR-19: constrain the server-side SEARCH to the UID window that
// can still hold unhandled messages, so a caught-up source does not
// re-transfer its whole match history every pass. "n:*" is inclusive of
// the mailbox's highest UID even when that UID is below n (RFC 3501
// seq-number semantics), so the defensive local filter below stays —
// the range is an optimization, never the correctness boundary.
if cp != nil && eff.LastUID > 0 && eff.LastUID < math.MaxUint32 {
criteria.Uid = new(imap.SeqSet)
criteria.Uid.AddRange(eff.LastUID+1, 0) // Stop 0 renders as "*"
}
uids, e := c.UidSearch(criteria)
if e != nil {
return nil, next, e
}
sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] })
// truncated reports whether the per-pass cap shortened the UID list.
// Review COR-1: when it did, the SEARCH did NOT consider every matching
// message, so the high-water shortcut must stay off — otherwise UIDs past
// the cap would land below the new checkpoint and be dropped forever.
truncated := false
if cp != nil {
// Incremental ingestion: everything at or below the EFFECTIVE
// checkpoint has already been durably handled by an earlier pass
// (or the UID space was reset and nothing has — review CR-15).
filtered := uids[:0]
for _, uid := range uids {
if uid > eff.LastUID {
filtered = append(filtered, uid)
}
}
uids = filtered
if len(uids) > searchBatch*maxIngestBatches {
uids = uids[:searchBatch*maxIngestBatches]
truncated = true
}
} else if len(uids) > searchBatch {
// Preview mode: the most recent matches, best effort.
uids = uids[len(uids)-searchBatch:]
}
out := []SignalInput{}
for start := 0; start < len(uids); start += searchBatch {
batch := uids[start:min(start+searchBatch, len(uids))]
seq := new(imap.SeqSet)
seq.AddNum(batch...)
ch := make(chan *imap.Message, len(batch))
done := make(chan error, 1)
go func() { done <- c.UidFetch(seq, []imap.FetchItem{imap.FetchUid, imap.FetchEnvelope}, ch) }()
for msg := range ch {
if msg.Uid > next.LastUID {
next.LastUID = msg.Uid
}
if msg.Envelope == nil {
continue
}
env := msg.Envelope
id := env.MessageId
if id == "" {
id = fmt.Sprintf("%s:%d:%d", folder, status.UidValidity, msg.Uid)
}
names := []string{}
for _, a := range env.From {
names = append(names, a.Address())
}
out = append(out, SignalInput{ExternalID: id, Title: env.Subject, Summary: strings.Join(names, ", "), OccurredAt: env.Date})
}
if e := <-done; e != nil {
// Partial results (review follow-up): a fetch can fail after an
// earlier batch (or part of this one) already produced events.
// Return what was found; callers persist it but must NOT advance
// the checkpoint, so the failed batch is retried and deduped.
return out, next, e
}
}
if cp != nil && !truncated && status.UidNext > next.LastUID+1 {
// The SEARCH considered every message in the folder (the cap did not
// shorten it), so non-matching UIDs below the mailbox high-water
// mark can be skipped for good; anything arriving after this snapshot
// stays above the checkpoint. Review COR-1: this shortcut is OFF
// after truncation — remaining matches get a future pass instead of
// being skipped forever.
next.LastUID = status.UidNext - 1
}
return out, next, nil
}
func LoadMailbox(ctx context.Context, d *db.DB, user string) (Mailbox, error) {
v, e := db.One(ctx, d, `SELECT to_jsonb(m) FROM "ImapConfig" m WHERE "userId"=$1`, user)
if e != nil {
return Mailbox{}, e
}
s := func(k string) string { v, _ := v[k].(string); return v }
cfg := config.Load()
pw, e := crypt.Decrypt(s("password"), cfg.EncryptionSecret())
if e != nil {
// Review OPS-01: rows encrypted before ENCRYPTION_KEY existed used the
// auth-secret-derived key. Decrypt with the legacy key and re-encrypt
// under the dedicated key so the migration completes transparently.
legacy, legacyErr := crypt.Decrypt(s("password"), cfg.Secret())
if legacyErr != nil {
return Mailbox{}, e
}
pw = legacy
if cfg.EncryptionSecret() != cfg.Secret() {
if enc, encErr := crypt.Encrypt(pw, cfg.EncryptionSecret()); encErr == nil {
// Review CR-21: compare-and-swap against the EXACT ciphertext
// this pass decrypted. A password saved concurrently between
// the read and this write must win; zero updated rows means
// exactly that, not permission to overwrite.
res, updErr := d.ExecContext(ctx, `UPDATE "ImapConfig" SET password=$1 WHERE "userId"=$2 AND password=$3`, enc, user, s("password"))
if updErr != nil {
log.Printf("imap: re-encrypt stored password under ENCRYPTION_KEY: %v", updErr)
} else if n, _ := res.RowsAffected(); n == 0 {
log.Printf("imap: stored password changed during key migration; keeping the newer value")
}
}
}
}
port, _ := v["port"].(float64)
tls, _ := v["tls"].(bool)
return Mailbox{s("host"), s("username"), pw, s("folder"), int(port), tls}, nil
}
func PublicSourceTypes() []db.Object {
return []db.Object{{"type": "email", "label": "Email (IMAP)", "description": "Search your configured mailbox for matching messages", "stub": false, "configFields": []db.Object{{"key": "from", "label": "From", "placeholder": "sender@domain.com"}, {"key": "subject", "label": "Subject", "placeholder": "Subject contains"}}}}
}
// CheckResult is the structured outcome of one signal pass (review CR-16).
// Sources counts the sources considered, Created the signals inserted, and
// Failed the sources whose check did not complete successfully (mailbox
// connection/search failure, signal insert failure, checkpoint failure or
// status-write failure). Per-source detail is persisted on
// SignalSource.lastStatus for the UI. The returned error is reserved for
// pass-level infrastructure failures (loading the source list): per-source
// failures live in Failed so scheduler backoff reacts to real integration
// failures while a manual check still answers 200 with per-source outcomes.
type CheckResult struct{ Sources, Created, Failed int }
func RunSignalChecks(ctx context.Context, d *db.DB, user, bookmark string) (CheckResult, error) {
res := CheckResult{}
sources, e := db.Many(ctx, d, `SELECT to_jsonb(s) FROM "SignalSource" s WHERE "ownerId"=$1 AND enabled AND ($2='' OR "bookmarkId"=$2)`, user, bookmark)
if e != nil {
return res, e
}
res.Sources = len(sources)
if len(sources) == 0 {
return res, nil
}
// Load and connect once for the whole pass; every source of a user shares
// the same mailbox credentials.
m, connErr := LoadMailbox(ctx, d, user)
var c *client.Client
if connErr == nil {
c, connErr = Connect(ctx, m)
if connErr == nil {
stop := context.AfterFunc(ctx, func() { c.Terminate() })
defer stop()
defer c.Logout()
}
}
for _, src := range sources {
sourceID := fmt.Sprint(src["id"])
status := "Checked"
events := []SignalInput{}
e := connErr
var next Checkpoint
if e == nil {
cfg, _ := src["config"].(map[string]any)
from, _ := cfg["from"].(string)
subject, _ := cfg["subject"].(string)
// Incremental, checkpointed ingestion (review SYNC-02): only UIDs
// newer than the durable checkpoint are processed, in bounded
// batches, until the source is caught up. Review COR-3: a failed
// checkpoint load is surfaced as an error for this source instead
// of silently degrading to a zero checkpoint (full re-scan).
var cp Checkpoint
if cp, e = LoadCheckpoint(ctx, d, sourceID); e == nil {
events, next, e = SearchConnFrom(c, m, from, subject, cp)
}
}
// Persist whatever events this source produced. On a partial search
// failure the caller still inserts them; the checkpoint stays put so
// the failed batch is retried and ON CONFLICT dedupes the rest.
insertEvents := func() error {
for _, s := range events {
when := s.OccurredAt
if when.IsZero() {
when = time.Now()
}
r, err := d.ExecContext(ctx, `INSERT INTO "Signal"(id,"sourceType",title,summary,"externalId","occurredAt","sourceId","bookmarkId","ownerId") VALUES($1,'email',$2,$3,$4,$5,$6,$7,$8) ON CONFLICT("sourceId","externalId") DO NOTHING`, db.NewID(), s.Title, s.Summary, s.ExternalID, when, src["id"], src["bookmarkId"], user)
if err != nil {
return err
}
n, _ := r.RowsAffected()
res.Created += int(n)
}
return nil
}
if e != nil {
// Connection/search failures are recorded per source (lastStatus)
// and counted in Failed (review CR-16) — they are NOT folded into
// the pass-level error, so a manual check still answers 200 with
// the per-source outcome and db.ErrNotFound from a missing
// mailbox configuration cannot surface as a misleading 404. The
// scheduler reads Failed and backs off on repeated failures.
status = "Error: mailbox connection or search failed"
res.Failed++
// Review follow-up: a search that failed partway may still have
// found real messages. Keep them instead of discarding the whole
// pass; the checkpoint is deliberately not saved below.
if len(events) > 0 {
if err := insertEvents(); err != nil {
status = "Error: signal insert failed"
}
}
} else if err := insertEvents(); err != nil {
// Review COR-5: record the per-source failure in lastStatus
// and continue with the remaining sources instead of aborting
// the whole pass. The checkpoint does NOT advance, so the
// failed event and the rest of the batch are retried next
// pass (ON CONFLICT dedupes the ones already inserted).
status = "Error: signal insert failed"
res.Failed++
} else if err := SaveCheckpoint(ctx, d, sourceID, next); err != nil {
// Advance the checkpoint only after the batch is durably handled.
status = "Error: checkpoint save failed"
res.Failed++
}
// Record this source's status even on error so the UI can surface it.
if _, err := d.ExecContext(ctx, `UPDATE "SignalSource" SET "lastCheckedAt"=now(),"lastStatus"=$1,"updatedAt"=now() WHERE id=$2`, status, src["id"]); err != nil {
res.Failed++
}
}
return res, nil
}
+31
View File
@@ -0,0 +1,31 @@
package signals
import (
"context"
"strings"
"testing"
"time"
)
// Review NET-02: mailbox credentials must never travel over a plaintext
// socket unless the explicit development fixture flag is set.
func TestPlaintextTransportRejected(t *testing.T) {
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "")
if e := checkTransport(Mailbox{TLS: false}); e == nil {
t.Fatal("plaintext mailbox accepted without the development flag")
}
if e := checkTransport(Mailbox{TLS: true}); e != nil {
t.Fatalf("TLS mailbox rejected: %v", e)
}
// The guard runs before any dial, so Connect fails fast without network.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, e := Connect(ctx, Mailbox{Host: "127.0.0.1", Port: 1, TLS: false})
if e == nil || !strings.Contains(e.Error(), "TLS is required") {
t.Fatalf("plaintext Connect was not refused up front: %v", e)
}
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1")
if e := checkTransport(Mailbox{TLS: false}); e != nil {
t.Fatalf("development fixture flag did not permit plaintext: %v", e)
}
}