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