Address code review findings across API, web and deployment
release gate / check (push) Failing after 6m43s
release gate / check (push) Failing after 6m43s
High severity: - wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup - bucket public rate limits on effective client IP, add per-bookmark budget, and forward the signed browser identity from the share page - disable first-run bootstrap in server deployments and document seeding - serialize last-admin demotion in one advisory-locked transaction Medium/low: - cap and batch share recipient inserts - add a shared outbound budget for preview/CardDAV/IMAP/signal work - pin CardDAV dials to 443 and bound IMAP logout - fix rightward kanban column drags and normalize cards on column delete - classify backend outages in auth guards, add request timeouts and an error boundary, remove the dead login route, surface throttling - enforce pinned/unpinned page reorder grouping server-side - avoid preview work for stale bookmark versions; drop duplicate dialog fetch - sequence-guard collection/tag/calendar/quick-find loads - validate property config and fix MULTI_SELECT/DATE filter inputs - document index maintenance; apply same-origin check to sign-out - trim signup names, unique shared dates and kanban keys (migration 010), reset reference cache on identity changes, hide OIDC issuer, return 413 for oversized direct bodies, escape Markdown link destinations - remove dead template files and duplicate test helper Verified with go build/vet, full PostgreSQL race suite, db convergence, tsc, both ESLint configs and a Next production build.
This commit is contained in:
@@ -40,7 +40,13 @@ func failure(w http.ResponseWriter, status int, s string) {
|
||||
}
|
||||
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 {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
// Review L-8: oversized is 413, not malformed JSON.
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
failure(w, 413, "Request body too large (limit 1 MiB)")
|
||||
return false
|
||||
}
|
||||
failure(w, 400, "Invalid JSON")
|
||||
return false
|
||||
}
|
||||
@@ -69,6 +75,9 @@ func HandleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
failure(w, 400, "Invalid email")
|
||||
return
|
||||
}
|
||||
// Review L-2: a display name of only whitespace is not a name. Trim, then
|
||||
// fall back to the email local-part when nothing remains.
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if len([]rune(req.Name)) > maxSignupNameLen {
|
||||
failure(w, 400, "Name must be at most 120 characters")
|
||||
return
|
||||
@@ -144,6 +153,13 @@ func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool {
|
||||
failure(w, 415, "Content-Type must be application/json")
|
||||
return false
|
||||
}
|
||||
return checkOrigin(w, r)
|
||||
}
|
||||
|
||||
// checkOrigin rejects a cross-origin Origin when one is present. Extracted
|
||||
// from checkAuthRequest so the sign-out endpoint can share the same guard
|
||||
// without forcing a JSON content type on legacy cookie clients (review M-16).
|
||||
func checkOrigin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
allowed, err := url.Parse(config.Load().NextAuthURL)
|
||||
scheme := "http"
|
||||
@@ -236,6 +252,12 @@ func HandleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
failure(w, 405, "Use POST to sign out")
|
||||
return
|
||||
}
|
||||
// Review M-16: keep sign-out consistent with the rest of the auth
|
||||
// surface. A browser cross-site POST is rejected; idempotent calls
|
||||
// for an already-invalid session still succeed below.
|
||||
if !checkOrigin(w, r) {
|
||||
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 {
|
||||
|
||||
+21
-15
@@ -35,13 +35,13 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
@@ -169,9 +169,6 @@ func ResetRateLimits() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -285,14 +282,23 @@ func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc {
|
||||
// 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.
|
||||
// Limiter wraps a handler with a simple per-client fixed-window rate limit.
|
||||
// Review H-2: this buckets on EffectiveClientIP (the signed forwarded browser
|
||||
// identity when the trusted Next server attested one), not the raw direct
|
||||
// peer. Behind the Next proxy every public request otherwise shared one
|
||||
// instance-wide bucket, so a single caller could deny the endpoint to all
|
||||
// users. Loopback is not exempt — public endpoints apply to every peer.
|
||||
func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return LimiterKeyed(s, limit, window, EffectiveClientIP)
|
||||
}
|
||||
|
||||
// LimiterKeyed is Limiter with a caller-supplied bucket key. It backs the
|
||||
// second public-share layer (review H-2): one hot bookmark id gets its own
|
||||
// generous budget in addition to the per-client one.
|
||||
func LimiterKeyed(s *limiterStore, limit int, window time.Duration, key func(*http.Request) string) 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 {
|
||||
if ok, retry := s.allow(key(r), limit, window, time.Now()); !ok {
|
||||
tooManyRequests(w, retry)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,8 +93,8 @@ func TestFailureTrackerEviction(t *testing.T) {
|
||||
func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
|
||||
|
||||
func TestRateLimitMiddleware(t *testing.T) {
|
||||
resetRateLimits()
|
||||
t.Cleanup(resetRateLimits)
|
||||
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.
|
||||
@@ -109,7 +109,7 @@ func TestRateLimitMiddleware(t *testing.T) {
|
||||
t.Fatalf("loopback login exempt: got %d", loopLast.Code)
|
||||
}
|
||||
// Signup keeps the loopback exemption for host-local consumers.
|
||||
resetRateLimits()
|
||||
ResetRateLimits()
|
||||
var loopSignup *httptest.ResponseRecorder
|
||||
for i := 0; i < signupIPLimit*2; i++ {
|
||||
loopSignup = httptest.NewRecorder()
|
||||
@@ -118,7 +118,7 @@ func TestRateLimitMiddleware(t *testing.T) {
|
||||
if loopSignup.Code != 204 {
|
||||
t.Fatalf("loopback signup limited with %d", loopSignup.Code)
|
||||
}
|
||||
resetRateLimits()
|
||||
ResetRateLimits()
|
||||
|
||||
// Non-loopback peers get 429 after the window limit, with Retry-After.
|
||||
remote := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
@@ -136,7 +136,7 @@ func TestRateLimitMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
// Signup limiter counts independently.
|
||||
resetRateLimits()
|
||||
ResetRateLimits()
|
||||
var signupLast *httptest.ResponseRecorder
|
||||
for i := 0; i < signupIPLimit+1; i++ {
|
||||
signupLast = httptest.NewRecorder()
|
||||
@@ -207,10 +207,48 @@ func TestSignedForwardedClientIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Review H-2: public endpoints must bucket on the signed forwarded browser
|
||||
// identity too, not the shared Next container peer.
|
||||
func TestPublicLimiterUsesForwardedIP(t *testing.T) {
|
||||
t.Setenv("NEXTAUTH_SECRET", "")
|
||||
t.Setenv("AUTH_SECRET", "public-limiter-forward-secret")
|
||||
ts := time.Now().Unix()
|
||||
sign := func(ip string) (string, string) {
|
||||
mac := hmac.New(sha256.New, []byte("public-limiter-forward-secret"))
|
||||
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
|
||||
return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
handler := Limiter(NewLimiterStore(), 2, time.Minute)(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
do := func(ip string) int {
|
||||
r := httptest.NewRequest("GET", "/api/public/bookmarks/x", nil)
|
||||
r.RemoteAddr = "10.0.0.5:1234"
|
||||
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 < 2; i++ {
|
||||
if code := do("198.51.100.1"); code != http.StatusOK {
|
||||
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)
|
||||
}
|
||||
if code := do("198.51.100.2"); code != http.StatusOK {
|
||||
t.Fatalf("browser 2 blocked by browser 1: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
|
||||
t.Setenv("NEXTAUTH_SECRET", "")
|
||||
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
|
||||
resetRateLimits()
|
||||
ResetRateLimits()
|
||||
ts := time.Now().Unix()
|
||||
sign := func(ip string) (string, string) {
|
||||
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
|
||||
@@ -241,5 +279,5 @@ func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
|
||||
if code := do("198.51.100.2"); code != 200 {
|
||||
t.Fatalf("browser 2 blocked by browser 1: %d", code)
|
||||
}
|
||||
resetRateLimits()
|
||||
ResetRateLimits()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package authguard
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/andrew/go-computer/internal/auth"
|
||||
"github.com/andrew/go-computer/internal/config"
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
@@ -11,6 +13,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// errLastAdmin is the transactional refusal to remove the final
|
||||
// administrator (review H-4). It is raised inside the locked transaction so
|
||||
// the count and the update cannot be interleaved by a concurrent demotion.
|
||||
var errLastAdmin = errors.New("at least one administrator must remain")
|
||||
|
||||
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
|
||||
@@ -43,21 +50,46 @@ func HandleAdminPatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
|
||||
return
|
||||
}
|
||||
// Review SEC-11: an installation must keep at least one administrator.
|
||||
// Review SEC-11/H-4: an installation must keep at least one administrator.
|
||||
// Self-demotion is already refused; this closes the "demote the last
|
||||
// other admin" path.
|
||||
// other admin" path. The role re-read, count and update all run in ONE
|
||||
// transaction serialized by an advisory lock, so two admins demoting
|
||||
// each other concurrently can no longer both observe admins == 2 and
|
||||
// leave zero. The pre-transaction role read above only picks the audit
|
||||
// message; the decision uses the locked value.
|
||||
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
|
||||
}
|
||||
e = db.Transaction(r.Context(), d, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(r.Context(), `SELECT pg_advisory_xact_lock(927439)`); e != nil {
|
||||
return e
|
||||
}
|
||||
var current string
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT role FROM "User" WHERE id=$1`, b.UserID).Scan(¤t); e != nil {
|
||||
return e
|
||||
}
|
||||
if current == "ADMIN" {
|
||||
var admins int
|
||||
if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); e != nil {
|
||||
return e
|
||||
}
|
||||
if admins <= 1 {
|
||||
return errLastAdmin
|
||||
}
|
||||
}
|
||||
_, e := tx.ExecContext(r.Context(), `UPDATE "User" SET role=$1,"updatedAt"=now() WHERE id=$2`, b.Role, b.UserID)
|
||||
return e
|
||||
})
|
||||
} else {
|
||||
e = repo.SetRole(r.Context(), b.UserID, b.Role)
|
||||
}
|
||||
if e := repo.SetRole(r.Context(), b.UserID, b.Role); e != nil {
|
||||
if errors.Is(e, sql.ErrNoRows) {
|
||||
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(e, errLastAdmin) {
|
||||
handlers.WriteJSON(w, 400, handlers.Error{Error: "At least one administrator must remain"})
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DBURL returns the database connection string, preferring DATABASE_URL and
|
||||
@@ -76,11 +78,23 @@ func (c *Config) CookieSecure() bool {
|
||||
return err == nil && u.Scheme == "https"
|
||||
}
|
||||
|
||||
// adminEmailsCache memoizes the parsed ADMIN_EMAILS value by its raw env
|
||||
// string (review M-12: IsAdminEmail runs on the auth hot path and re-split
|
||||
// and re-lowercased the list on every call). Keying on the raw value keeps
|
||||
// t.Setenv-based tests correct without a process-global startup snapshot.
|
||||
var adminEmailsCache struct {
|
||||
mu sync.Mutex
|
||||
raw string
|
||||
out []string
|
||||
}
|
||||
|
||||
// AdminEmails splits env ADMIN_EMAILS on commas, trims, lowercases.
|
||||
func AdminEmails() []string {
|
||||
s := os.Getenv("ADMIN_EMAILS")
|
||||
if s == "" {
|
||||
return nil
|
||||
adminEmailsCache.mu.Lock()
|
||||
defer adminEmailsCache.mu.Unlock()
|
||||
if adminEmailsCache.raw == s {
|
||||
return adminEmailsCache.out
|
||||
}
|
||||
var out []string
|
||||
for _, email := range strings.Split(s, ",") {
|
||||
@@ -88,6 +102,7 @@ func AdminEmails() []string {
|
||||
out = append(out, email)
|
||||
}
|
||||
}
|
||||
adminEmailsCache.raw, adminEmailsCache.out = s, out
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -111,3 +126,18 @@ func IsAdminEmail(email string) bool {
|
||||
func AllowBootstrapSignup() bool {
|
||||
return os.Getenv("ALLOW_BOOTSTRAP_SIGNUP") != "0"
|
||||
}
|
||||
|
||||
// Validate fails fast on configuration the app cannot use, instead of
|
||||
// discovering it on the first request (review M-12).
|
||||
func (c *Config) Validate() error {
|
||||
if strings.TrimSpace(c.Secret()) == "" {
|
||||
return fmt.Errorf("AUTH_SECRET (or NEXTAUTH_SECRET) must be set")
|
||||
}
|
||||
if c.NextAuthURL != "" {
|
||||
u, err := url.Parse(c.NextAuthURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return fmt.Errorf("NEXTAUTH_URL must be an absolute http(s) URL, got %q", c.NextAuthURL)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,37 @@ func TestSecretPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Review M-12: fail fast on unusable configuration at startup.
|
||||
func TestValidate(t *testing.T) {
|
||||
if err := (&Config{}).Validate(); err == nil {
|
||||
t.Fatal("missing secret passed validation")
|
||||
}
|
||||
if err := (&Config{AuthSecret: "secret", NextAuthURL: "not a url"}).Validate(); err == nil {
|
||||
t.Fatal("relative NEXTAUTH_URL passed validation")
|
||||
}
|
||||
for _, u := range []string{"", "http://localhost:3000", "https://app.example.com"} {
|
||||
if err := (&Config{AuthSecret: "secret", NextAuthURL: u}).Validate(); err != nil {
|
||||
t.Fatalf("valid configuration %q rejected: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Review M-12: the parsed ADMIN_EMAILS list is memoized but still follows
|
||||
// env changes (tests and operator restarts rely on that).
|
||||
func TestAdminEmailsTracksEnv(t *testing.T) {
|
||||
t.Setenv("ADMIN_EMAILS", " A@Example.com ,, b@example.com ")
|
||||
if got := AdminEmails(); len(got) != 2 || got[0] != "a@example.com" || got[1] != "b@example.com" {
|
||||
t.Fatalf("unexpected parse: %v", got)
|
||||
}
|
||||
t.Setenv("ADMIN_EMAILS", "")
|
||||
if got := AdminEmails(); len(got) != 0 {
|
||||
t.Fatalf("cache did not follow the env change: %v", got)
|
||||
}
|
||||
if IsAdminEmail("a@example.com") {
|
||||
t.Fatal("stale admin email after env change")
|
||||
}
|
||||
}
|
||||
|
||||
// Review OPS-01: a dedicated ENCRYPTION_KEY decouples integration-credential
|
||||
// encryption from the session signing secret.
|
||||
func TestEncryptionSecretPrecedence(t *testing.T) {
|
||||
|
||||
@@ -23,7 +23,10 @@ const BookmarkOwnerView = `to_jsonb(b) || jsonb_build_object(
|
||||
// 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.
|
||||
// recipient list, no alerts, no mailbox-watch settings, no board placement
|
||||
// (kanbanColumnId/kanbanPosition). The legacy kanbanStatus badge IS included
|
||||
// deliberately: it is status, not placement, and API.md/web/AGENTS.md say so
|
||||
// (review L-10).
|
||||
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",
|
||||
|
||||
@@ -318,6 +318,14 @@ func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
// Review M-10: reject a stale base version BEFORE doing outbound preview
|
||||
// work. The transaction below still re-checks under the user lock; this
|
||||
// early check only avoids spending up to 8 seconds and the shared preview
|
||||
// budget on a request that can only end in 409.
|
||||
if stored, _ := existing["version"].(float64); int(stored) != baseVersion {
|
||||
respond(w, 0, nil, conflict("Bookmark was modified elsewhere; reload to see the latest version"))
|
||||
return
|
||||
}
|
||||
if s := str(d, "url"); s != "" && s != str(existing, "url") {
|
||||
addPreview(r.Context(), previewKey(r), d, false)
|
||||
}
|
||||
@@ -400,7 +408,7 @@ func HandleBookmarksShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, e := ids(b, "userIds")
|
||||
list, e := cappedIDs(b, "userIds", maxShareRecipients)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
|
||||
@@ -306,7 +306,7 @@ func HandleCollectionShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, e := ids(b, "userIds")
|
||||
list, e := cappedIDs(b, "userIds", maxShareRecipients)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
@@ -431,7 +431,18 @@ func validatePropertyConfig(typ string, raw any, existing []object) (object, []s
|
||||
}
|
||||
return object{"mode": mode}, nil, nil
|
||||
default:
|
||||
if m, ok := raw.(object); ok && raw != nil && len(m) > 0 {
|
||||
// Review M-13: these types take no configuration. Accept null or an
|
||||
// empty object and reject every other value — silently replacing a
|
||||
// malformed config with {} told the client it had configured
|
||||
// something it had not.
|
||||
if raw == nil {
|
||||
return object{}, nil, nil
|
||||
}
|
||||
m, ok := raw.(object)
|
||||
if !ok {
|
||||
return nil, nil, bad("config must be an object")
|
||||
}
|
||||
if len(m) > 0 {
|
||||
return nil, nil, bad(typ + " properties take no config")
|
||||
}
|
||||
return object{}, nil, nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -36,7 +37,19 @@ 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 {
|
||||
if err := dec.Decode(&b); err != nil {
|
||||
// Review L-8: an oversized body is 413, not malformed JSON. The Next
|
||||
// proxy already answers 413; direct API callers deserve the same
|
||||
// class of error.
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeJSON(w, 413, Error{"Request body too large (limit 1 MiB)"})
|
||||
return nil, false
|
||||
}
|
||||
writeJSON(w, 400, Error{"Invalid JSON object"})
|
||||
return nil, false
|
||||
}
|
||||
if b == nil {
|
||||
writeJSON(w, 400, Error{"Invalid JSON object"})
|
||||
return nil, false
|
||||
}
|
||||
@@ -98,6 +111,27 @@ func ids(b object, k string) ([]string, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Share-list caps (review M-1). A 1 MiB JSON body can carry tens of
|
||||
// thousands of ids; each one used to become an INSERT in a user-locked
|
||||
// transaction. These are product limits, not storage limits.
|
||||
const (
|
||||
maxShareRecipients = 100
|
||||
maxSharedDateRecipients = 200
|
||||
)
|
||||
|
||||
// cappedIDs is ids plus a length cap (review M-1), returning a clear 400.
|
||||
func cappedIDs(b object, key string, max int) ([]string, error) {
|
||||
list, e := ids(b, key)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(list) > max {
|
||||
return nil, bad(fmt.Sprintf("%s may contain at most %d entries", key, max))
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func fields(b object, allowed string) object {
|
||||
out := object{}
|
||||
for _, k := range strings.Fields(allowed) {
|
||||
@@ -254,11 +288,23 @@ func reorder(r *http.Request, table, col string, order []string) error {
|
||||
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
|
||||
}
|
||||
|
||||
// outboundBusy answers 429 with Retry-After when the shared outbound budget
|
||||
// is exhausted (review M-2).
|
||||
func outboundBusy(w http.ResponseWriter, retry time.Duration) {
|
||||
if retry < time.Second {
|
||||
retry = time.Second
|
||||
}
|
||||
return nil
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
||||
writeJSON(w, 429, Error{"Too much outbound work right now; try again shortly"})
|
||||
}
|
||||
|
||||
// shareRows inserts every recipient in ONE statement (review M-1). table and
|
||||
// key come only from the server-side call sites, never from request input.
|
||||
func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING`, id, list)
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -230,16 +230,32 @@ func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return bad("A board needs at least one column")
|
||||
}
|
||||
dest = str(remaining[0], "id")
|
||||
// The destination column's legacy key, when it has one, becomes the
|
||||
// moved cards' kanbanStatus so the two representations do not point
|
||||
// at the deleted column (review M-5/L-12). Custom columns have no
|
||||
// key; their cards keep the legacy status as a documented fallback.
|
||||
destKey := ""
|
||||
for _, c := range remaining {
|
||||
if str(c, "id") == dest {
|
||||
if k := str(c, "key"); validStatus(k) {
|
||||
destKey = k
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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))
|
||||
// Positions are normalized below too (review M-5), so reload order is
|
||||
// deterministic instead of interleaving with the destination's cards.
|
||||
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 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
moveIDs := make([]string, 0, len(bms))
|
||||
moving := map[string]bool{}
|
||||
for _, b := range bms {
|
||||
if kanban.ResolveColumnID(b, cols) == id {
|
||||
moveIDs = append(moveIDs, str(b, "id"))
|
||||
moving[str(b, "id")] = true
|
||||
}
|
||||
}
|
||||
if len(moveIDs) > 0 {
|
||||
@@ -248,7 +264,20 @@ func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,"kanbanStatus"=CASE WHEN $4='' THEN "kanbanStatus" ELSE $4::"KanbanStatus" END,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[]) AND "ownerId"=$3`, dest, moveIDs, user(r), destKey); e != nil {
|
||||
return e
|
||||
}
|
||||
// Append the moved cards after the destination's existing order
|
||||
// and renumber contiguously, exactly like a normal card move.
|
||||
order := make([]string, 0, len(bms))
|
||||
for _, b := range bms {
|
||||
bid := str(b, "id")
|
||||
if !moving[bid] && kanban.ResolveColumnID(b, remaining) == dest {
|
||||
order = append(order, bid)
|
||||
}
|
||||
}
|
||||
order = append(order, moveIDs...)
|
||||
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanPosition"=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$2`, order, user(r)); e != nil {
|
||||
return e
|
||||
}
|
||||
moved = len(moveIDs)
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
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/outbound"
|
||||
"github.com/andrew/go-computer/internal/signals"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -18,12 +20,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// carddavDial pins production CardDAV traffic to 443 (review M-3), matching
|
||||
// previews (80/443) and IMAP (143/993). Without it a user could make the
|
||||
// server TLS-handshake and send Basic-auth credentials to any public port.
|
||||
// The fixture/LAN escape hatch keeps loopback fakes and deliberate private
|
||||
// deployments working.
|
||||
func carddavDial(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
if netguard.AllowPrivate() {
|
||||
return netguard.DialContext(ctx, network, address)
|
||||
}
|
||||
return netguard.DialContextPorts(ctx, network, address, 443)
|
||||
}
|
||||
|
||||
// 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},
|
||||
Transport: &http.Transport{DialContext: carddavDial},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
@@ -65,7 +79,7 @@ func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 0, nil, bad("Date required"))
|
||||
return
|
||||
}
|
||||
list, e := ids(b, "recipientIds")
|
||||
list, e := cappedIDs(b, "recipientIds", maxSharedDateRecipients)
|
||||
if e != nil || len(list) == 0 {
|
||||
respond(w, 0, nil, bad("Recipients required"))
|
||||
return
|
||||
@@ -81,7 +95,11 @@ func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
|
||||
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"]})
|
||||
// Review L-3: re-sharing the same creator/recipient/date updates
|
||||
// the note instead of inserting a duplicate calendar row.
|
||||
v, e := db.One(r.Context(), tx, `INSERT INTO "SharedDate"(id,date,note,"creatorId","recipientId") VALUES($1,$2,$3,$4,$5)
|
||||
ON CONFLICT ("creatorId","recipientId",date) DO UPDATE SET note=EXCLUDED.note
|
||||
RETURNING to_jsonb("SharedDate")`, db.NewID(), dt, b["note"], user(r), id)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
@@ -149,6 +167,13 @@ func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 0, nil, bad("Nextcloud URL must use https"))
|
||||
return
|
||||
}
|
||||
// Review M-3: the transport pins production dials to 443; reject an
|
||||
// explicit non-standard port up front for a clear error instead of a
|
||||
// generic connection failure.
|
||||
if !netguard.AllowPrivate() && base.Port() != "" && base.Port() != "443" {
|
||||
respond(w, 0, nil, bad("Nextcloud URL must use port 443"))
|
||||
return
|
||||
}
|
||||
path := str(b, "cardDavPath")
|
||||
if path == "" {
|
||||
path = "/remote.php/dav/addressbooks/users/" + url.PathEscape(str(b, "username")) + "/contacts/"
|
||||
@@ -158,6 +183,15 @@ func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
// Review M-2: CardDAV sync draws from the shared outbound budget with
|
||||
// IMAP/preview/signal checks, so one user cannot open unbounded
|
||||
// long-lived connections or starve egress.
|
||||
release, retry, ok := outbound.Reserve("carddav:"+user(r), 10, time.Minute)
|
||||
if !ok {
|
||||
outboundBusy(w, retry)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
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 {
|
||||
@@ -493,11 +527,20 @@ func HandleImapTest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
m.Password = saved.Password
|
||||
}
|
||||
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
|
||||
if !ok {
|
||||
outboundBusy(w, retry)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
c, e := signals.Connect(r.Context(), m)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, bad("Mailbox connection or login failed"))
|
||||
return
|
||||
}
|
||||
// Review M-2: a hostile server must not hold the handler open during the
|
||||
// goodbye exchange after the test has already succeeded.
|
||||
c.Timeout = 5 * time.Second
|
||||
c.Logout()
|
||||
respond(w, 200, OK{true}, nil)
|
||||
}
|
||||
@@ -523,6 +566,12 @@ func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 0, nil, bad("Configure IMAP first"))
|
||||
return
|
||||
}
|
||||
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
|
||||
if !ok {
|
||||
outboundBusy(w, retry)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
c, e := signals.Connect(ctx, m)
|
||||
@@ -532,7 +581,9 @@ func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
stop := context.AfterFunc(ctx, func() { c.Terminate() })
|
||||
defer stop()
|
||||
defer c.Logout()
|
||||
// Keep the goodbye exchange bounded too (review M-2); searches above
|
||||
// stay under the caller's 45-second context.
|
||||
defer func() { c.Timeout = 5 * time.Second; c.Logout() }()
|
||||
searchFailed := false
|
||||
for _, b := range watched {
|
||||
events, e := signals.SearchConn(c, m, "", str(b, "imapQuery"))
|
||||
|
||||
@@ -131,6 +131,52 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
respond(w, 201, v, e)
|
||||
}
|
||||
|
||||
// reorderPages enforces the pinned/unpinned grouping invariant server-side
|
||||
// (review M-9). The list orders by pinned DESC, position, so a permutation
|
||||
// that interleaves the groups could never be displayed as requested. Rather
|
||||
// than mutating meaningless positions, positions are normalized INSIDE each
|
||||
// group: the submitted order determines the order of pinned pages among
|
||||
// pinned pages and unpinned among unpinned, regardless of interleaving.
|
||||
func reorderPages(r *http.Request, order []string) error {
|
||||
return db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
||||
if e := lockUser(r, tx); e != nil {
|
||||
return e
|
||||
}
|
||||
rows, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',id,'pinned',pinned) FROM "Page" WHERE "ownerId"=$1`, user(r))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(rows) != len(order) {
|
||||
return bad("Order must contain every record exactly once")
|
||||
}
|
||||
pinnedByID := make(map[string]bool, len(rows))
|
||||
for _, row := range rows {
|
||||
isPinned, _ := row["pinned"].(bool)
|
||||
pinnedByID[str(row, "id")] = isPinned
|
||||
}
|
||||
groups := map[bool][]string{}
|
||||
seen := make(map[string]bool, len(order))
|
||||
for _, id := range order {
|
||||
isPinned, ok := pinnedByID[id]
|
||||
if !ok || seen[id] {
|
||||
return bad("Order must contain every record exactly once")
|
||||
}
|
||||
seen[id] = true
|
||||
groups[isPinned] = append(groups[isPinned], id)
|
||||
}
|
||||
for _, ids := range [][]string{groups[true], groups[false]} {
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" AS p SET position=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(pid,ord) WHERE p.id=t.pid AND p."ownerId"=$2`, ids, user(r)); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := read(w, r)
|
||||
if !ok {
|
||||
@@ -138,7 +184,7 @@ func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
list, e := ids(b, "order")
|
||||
if e == nil {
|
||||
e = reorder(r, "Page", "ownerId", list)
|
||||
e = reorderPages(r, list)
|
||||
}
|
||||
respond(w, 200, OK{true}, e)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
"github.com/andrew/go-computer/internal/outbound"
|
||||
"github.com/andrew/go-computer/internal/signals"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -42,6 +43,15 @@ func HandleSignalsCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
// Review M-2: a manual check is a 45-second outbound pass; it draws
|
||||
// from the same budget as previews/IMAP/CardDAV so repeated clicks
|
||||
// cannot multiply concurrent mailbox connections.
|
||||
release, retry, ok := outbound.Reserve("signals:"+user(r), 10, time.Minute)
|
||||
if !ok {
|
||||
outboundBusy(w, retry)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
ctx := r.Context()
|
||||
leaseKey := "signal-poll:" + user(r)
|
||||
conn, e := database(r).Conn(ctx)
|
||||
|
||||
@@ -74,7 +74,7 @@ func HandleTagsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
}
|
||||
list, e := ids(b, "shareUserIds")
|
||||
list, e := cappedIDs(b, "shareUserIds", maxShareRecipients)
|
||||
if e != nil {
|
||||
respond(w, 0, nil, e)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rawCall issues a request with the client's cookies and explicit headers,
|
||||
// without the apiClient helper's automatic status assertion.
|
||||
func rawCall(t *testing.T, c apiClient, method, path string, body []byte, headers map[string]string) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
r = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.base+path, r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
res, err := c.c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
out, _ := io.ReadAll(res.Body)
|
||||
return res.StatusCode, out
|
||||
}
|
||||
|
||||
// Review H-4: two admins demoting each other must not both pass the
|
||||
// "at least one administrator" check. The advisory-locked transaction makes
|
||||
// one of them win and the other observe the committed count.
|
||||
func TestConcurrentAdminDemotionsKeepOneAdmin(t *testing.T) {
|
||||
srv, d := testServerDB(t)
|
||||
a, _ := accountClient(t, srv.URL, "admin-a@computer.test")
|
||||
b, bID := accountClient(t, srv.URL, "admin-b@computer.test")
|
||||
a.request("PATCH", "/api/admin/users", map[string]any{"userId": bID, "role": "ADMIN"}, 200)
|
||||
|
||||
aID := meID(a)
|
||||
demote := func(c apiClient, target string) int {
|
||||
body, _ := json.Marshal(map[string]any{"userId": target, "role": "USER"})
|
||||
status, _ := rawCall(t, c, "PATCH", "/api/admin/users", body, nil)
|
||||
return status
|
||||
}
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
statuses := make([]int, 2)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); <-start; statuses[0] = demote(a, bID) }()
|
||||
go func() { defer wg.Done(); <-start; statuses[1] = demote(b, aID) }()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
ok, refused := 0, 0
|
||||
for _, s := range statuses {
|
||||
switch s {
|
||||
case 200:
|
||||
ok++
|
||||
case 400, 403:
|
||||
// 400: still an admin, but the locked count now shows one.
|
||||
// 403: the other demotion committed first and this session is no
|
||||
// longer an admin by the time its request is authorized.
|
||||
refused++
|
||||
default:
|
||||
t.Fatalf("unexpected demotion status: %v", statuses)
|
||||
}
|
||||
}
|
||||
if ok != 1 || refused != 1 {
|
||||
t.Fatalf("wanted one successful and one refused demotion, got %v", statuses)
|
||||
}
|
||||
var admins int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM "User" WHERE role='ADMIN'`).Scan(&admins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if admins != 1 {
|
||||
t.Fatalf("concurrent demotions left %d administrators", admins)
|
||||
}
|
||||
}
|
||||
|
||||
// Review L-2: a whitespace-only display name is trimmed and falls back to the
|
||||
// email local-part instead of being stored verbatim.
|
||||
func TestSignupTrimsWhitespaceName(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c := apiClient{t, &http.Client{Jar: jar}, srv.URL}
|
||||
body := map[string]any{"email": "trimmed@computer.test", "password": "test-long-password", "name": " "}
|
||||
c.request("POST", "/api/signup", body, 201)
|
||||
c.request("POST", "/api/auth/login", body, 200)
|
||||
me := c.request("GET", "/api/me", nil, 200)
|
||||
user := me["user"].(map[string]any)
|
||||
if user["name"] != "trimmed" {
|
||||
t.Fatalf("whitespace name was not normalized: %v", user["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// Review M-1: share recipient arrays are capped before they become one
|
||||
// INSERT per id inside a user-locked transaction.
|
||||
func TestShareRecipientListsAreCapped(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
owner, _ := accountClient(t, srv.URL, "cap-owner@computer.test")
|
||||
id := owner.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.com/cap"}, 201)["id"].(string)
|
||||
|
||||
tooMany := make([]string, maxTestShareRecipients+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("user-%d", i)
|
||||
}
|
||||
owner.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": tooMany}, 400)
|
||||
|
||||
dates := make([]string, maxTestSharedDateRecipients+1)
|
||||
for i := range dates {
|
||||
dates[i] = fmt.Sprintf("user-%d", i)
|
||||
}
|
||||
owner.request("POST", "/api/shared-dates", map[string]any{"date": "2026-12-01", "recipientIds": dates}, 400)
|
||||
}
|
||||
|
||||
// Mirrors handlers.maxShareRecipients / maxSharedDateRecipients (unexported).
|
||||
const (
|
||||
maxTestShareRecipients = 100
|
||||
maxTestSharedDateRecipients = 200
|
||||
)
|
||||
|
||||
// Review L-8: an oversized direct API body is 413, not a misleading 400.
|
||||
func TestOversizedDirectBodyReturns413(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
owner, _ := accountClient(t, srv.URL, "oversized@computer.test")
|
||||
body := []byte(`{"url":"https://example.com","notes":"` + strings.Repeat("a", 1<<20) + `"}`)
|
||||
status, _ := rawCall(t, owner, "POST", "/api/bookmarks", body, nil)
|
||||
if status != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized body: wanted 413, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Review M-16: sign-out rejects a cross-site Origin like the other auth
|
||||
// endpoints, while an idempotent same-origin call still succeeds.
|
||||
func TestSignoutRejectsCrossOrigin(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
owner, _ := accountClient(t, srv.URL, "signout@computer.test")
|
||||
body := []byte(`{}`)
|
||||
status, _ := rawCall(t, owner, "POST", "/api/auth/signout", body, map[string]string{"Origin": "https://attacker.invalid"})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin signout: wanted 403, got %d", status)
|
||||
}
|
||||
status, _ = rawCall(t, owner, "POST", "/api/auth/signout", body, map[string]string{"Origin": srv.URL})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("same-origin signout: wanted 200, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Review M-9: the server normalizes positions inside the pinned/unpinned
|
||||
// groups instead of trusting a client to keep them apart.
|
||||
func TestPageReorderKeepsPinnedGrouping(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
owner, _ := accountClient(t, srv.URL, "reorder@computer.test")
|
||||
pinned := owner.request("POST", "/api/pages", map[string]any{"title": "Pinned", "pinned": true}, 201)["id"].(string)
|
||||
second := owner.request("POST", "/api/pages", map[string]any{"title": "Second"}, 201)["id"].(string)
|
||||
third := owner.request("POST", "/api/pages", map[string]any{"title": "Third"}, 201)["id"].(string)
|
||||
|
||||
// Interleaved request: unpinned pages first, the pinned page last.
|
||||
owner.request("PUT", "/api/pages", map[string]any{"order": []string{third, second, pinned}}, 200)
|
||||
|
||||
res, err := owner.c.Get(srv.URL + "/api/pages")
|
||||
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)
|
||||
}
|
||||
got := make([]string, 0, len(list))
|
||||
for _, p := range list {
|
||||
got = append(got, p["id"].(string))
|
||||
}
|
||||
want := []string{pinned, third, second}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("page order after reorder = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
owner.request("PUT", "/api/pages", map[string]any{"order": []string{pinned, second}}, 400)
|
||||
}
|
||||
|
||||
// Review M-13: non-object / non-empty config on types that take no config is
|
||||
// rejected instead of silently discarded.
|
||||
func TestPropertyConfigRejectsMalformedValues(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
owner, _ := accountClient(t, srv.URL, "props@computer.test")
|
||||
cid := owner.request("POST", "/api/collections", map[string]any{"name": "Config"}, 201)["id"].(string)
|
||||
for _, cfg := range []any{"x", []any{}, 5, true} {
|
||||
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Bad", "type": "TEXT", "config": cfg}, 400)
|
||||
}
|
||||
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Null", "type": "TEXT", "config": nil}, 201)
|
||||
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Empty", "type": "TEXT", "config": map[string]any{}}, 201)
|
||||
}
|
||||
|
||||
// Review L-3: re-sharing the same date updates the surviving row instead of
|
||||
// creating duplicate calendar entries.
|
||||
func TestSharedDateReshareIsIdempotent(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
owner, _ := accountClient(t, srv.URL, "dates-owner@computer.test")
|
||||
_, recipientID := accountClient(t, srv.URL, "dates-recipient@computer.test")
|
||||
|
||||
post := func(note string) []map[string]any {
|
||||
body, _ := json.Marshal(map[string]any{"date": "2026-11-05", "note": note, "recipientIds": []string{recipientID}})
|
||||
status, raw := rawCall(t, owner, "POST", "/api/shared-dates", body, nil)
|
||||
if status != 201 {
|
||||
t.Fatalf("shared-date create: %d %s", status, raw)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
first := post("first")
|
||||
second := post("second")
|
||||
if len(first) != 1 || len(second) != 1 {
|
||||
t.Fatalf("want one returned share, got %d and %d", len(first), len(second))
|
||||
}
|
||||
if second[0]["id"] != first[0]["id"] {
|
||||
t.Fatalf("re-share created a new row: %v vs %v", second[0]["id"], first[0]["id"])
|
||||
}
|
||||
if second[0]["note"] != "second" {
|
||||
t.Fatalf("re-share did not update the note: %v", second[0]["note"])
|
||||
}
|
||||
|
||||
res, err := owner.c.Get(srv.URL + "/api/shared-dates")
|
||||
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)
|
||||
}
|
||||
count := 0
|
||||
for _, row := range list {
|
||||
date, _ := row["date"].(string)
|
||||
if row["recipientId"] == recipientID && strings.HasPrefix(date, "2026-11-05") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("want one shared-date row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Review M-3: production CardDAV dials are pinned to 443.
|
||||
func TestCardDAVRejectsNonStandardPort(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "0")
|
||||
owner, _ := accountClient(t, srv.URL, "carddav@computer.test")
|
||||
v := owner.request("POST", "/api/contacts/sync", map[string]any{
|
||||
"nextcloudUrl": "https://198.51.100.10:8443",
|
||||
"username": "user",
|
||||
"password": "secret",
|
||||
}, 400)
|
||||
msg, _ := v["error"].(string)
|
||||
if !strings.Contains(msg, "443") {
|
||||
t.Fatalf("expected a port-443 error, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Review M-5: deleting a column appends its cards to the destination and
|
||||
// renumbers positions contiguously, so reload order is deterministic.
|
||||
func TestColumnDeleteNormalizesMovedCardPositions(t *testing.T) {
|
||||
srv, _ := testServerDB(t)
|
||||
owner, _ := accountClient(t, srv.URL, "columns@computer.test")
|
||||
var columns []map[string]any
|
||||
res, err := owner.c.Get(srv.URL + "/api/kanban/columns")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&columns); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if len(columns) < 2 {
|
||||
t.Fatalf("expected the seeded board, got %d columns", len(columns))
|
||||
}
|
||||
first, second := columns[0]["id"].(string), columns[1]["id"].(string)
|
||||
|
||||
mk := func(column, status, title string) string {
|
||||
return owner.request("POST", "/api/bookmarks", map[string]any{
|
||||
"url": "https://example.com/" + title, "title": title,
|
||||
"kanbanColumnId": column, "kanbanStatus": status,
|
||||
}, 201)["id"].(string)
|
||||
}
|
||||
mk(second, "TODO", "dest-1")
|
||||
mk(second, "TODO", "dest-2")
|
||||
mk(first, "INBOX", "moved-1")
|
||||
mk(first, "INBOX", "moved-2")
|
||||
|
||||
owner.request("DELETE", "/api/kanban/columns/"+first, nil, 200)
|
||||
|
||||
res, err = owner.c.Get(srv.URL + "/api/bookmarks?limit=200")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var bookmarks []map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&bookmarks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
positions := map[float64]bool{}
|
||||
for _, b := range bookmarks {
|
||||
if b["kanbanColumnId"] != second {
|
||||
t.Fatalf("bookmark %v was not moved to the destination", b["title"])
|
||||
}
|
||||
if b["kanbanStatus"] != "TODO" {
|
||||
t.Fatalf("legacy status for %v still points at the deleted column: %v", b["title"], b["kanbanStatus"])
|
||||
}
|
||||
positions[b["kanbanPosition"].(float64)] = true
|
||||
}
|
||||
if len(bookmarks) != 4 || len(positions) != 4 {
|
||||
t.Fatalf("want four contiguously positioned cards, got %d cards at %d positions", len(bookmarks), len(positions))
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if !positions[float64(i)] {
|
||||
t.Fatalf("positions are not contiguous 0..3: %v", positions)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -142,16 +142,25 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
|
||||
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).
|
||||
// Review SEC-10/H-2: public endpoints get two rate-limit layers keyed on
|
||||
// the EFFECTIVE client identity (signed forwarded browser IP, else the
|
||||
// direct peer), never the shared Next container address:
|
||||
// - per client: 30 public lookups/min, 20 previews/min;
|
||||
// - per public bookmark id: 120/min, so one hot shared link cannot
|
||||
// exhaust the instance-wide budget.
|
||||
// Preview is rate limited AFTER auth: unauthenticated requests must not
|
||||
// consume the bucket and deny previews to signed-in users.
|
||||
publicBookmarkLimiter := auth.NewLimiterStore()
|
||||
publicBookmarkIDLimiter := auth.NewLimiterStore()
|
||||
previewLimiter := auth.NewLimiterStore()
|
||||
publicLimit := auth.Limiter(publicBookmarkLimiter, 30, time.Minute)
|
||||
publicIDLimit := auth.LimiterKeyed(publicBookmarkIDLimiter, 120, time.Minute, func(r *http.Request) string {
|
||||
return "bookmark:" + r.PathValue("id")
|
||||
})
|
||||
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)))
|
||||
mux.HandleFunc("GET /share/bookmark/{id}", publicLimit(publicIDLimit(handlers.HandlePublicBookmark)))
|
||||
mux.HandleFunc("GET /api/public/bookmarks/{id}", publicLimit(publicIDLimit(handlers.HandlePublicBookmark)))
|
||||
mux.HandleFunc("GET /api/preview", authed(previewLimit(handlers.HandlePreview)))
|
||||
|
||||
return &http.Server{
|
||||
Handler: withDB(database, mux),
|
||||
|
||||
@@ -43,6 +43,21 @@ func BookmarkDocument(b db.Object) string {
|
||||
}
|
||||
return out + "\n" + strings.TrimSpace(text(b, "notes")) + "\n"
|
||||
}
|
||||
|
||||
// linkDestination wraps a URL in a CommonMark angle-bracket destination
|
||||
// (review L-11). Parentheses (and the whitespace validURL accepts) no longer
|
||||
// terminate the link, and the two characters that cannot appear unescaped
|
||||
// inside <...> are percent-encoded.
|
||||
func linkDestination(raw string) string {
|
||||
escaped := strings.NewReplacer(
|
||||
"<", "%3C",
|
||||
">", "%3E",
|
||||
"\r", "%0D",
|
||||
"\n", "%0A",
|
||||
).Replace(raw)
|
||||
return "<" + escaped + ">"
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -60,7 +75,7 @@ func PageDocument(p db.Object) string {
|
||||
if len(rs) > 160 {
|
||||
desc = string(rs[:160])
|
||||
}
|
||||
out += "- [" + title + "](" + text(b, "url") + ")"
|
||||
out += "- [" + title + "](" + linkDestination(text(b, "url")) + ")"
|
||||
if desc != "" {
|
||||
out += " — " + desc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
)
|
||||
|
||||
// Review L-11: link destinations are wrapped and escaped so parentheses or
|
||||
// angle brackets in a bookmark URL cannot terminate or extend the link.
|
||||
func TestPageDocumentEscapesLinkDestinations(t *testing.T) {
|
||||
out := PageDocument(db.Object{
|
||||
"title": "Links",
|
||||
"content": "",
|
||||
"bookmarks": []any{
|
||||
map[string]any{"bookmark": map[string]any{
|
||||
"title": "Odd",
|
||||
"url": "https://example.com/a(b)?x=<y>",
|
||||
}},
|
||||
},
|
||||
})
|
||||
want := "[Odd](<https://example.com/a(b)?x=%3Cy%3E>)"
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("link destination not escaped:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,11 @@ import (
|
||||
// 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}
|
||||
// GET /api/auth/oidc/status -> {enabled, issuerSet, clientIdSet, clientSecretSet}
|
||||
//
|
||||
// Review L-7: the issuer URL is deliberately NOT public. It is usually a
|
||||
// public hostname, but an internal one would become an information leak; the
|
||||
// authenticated settings surface can expose it when one exists.
|
||||
func HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -38,7 +42,6 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"issuerSet": s.IssuerSet,
|
||||
"clientIdSet": s.ClientIDSet,
|
||||
"clientSecretSet": s.ClientSecretSet,
|
||||
"issuer": s.Issuer,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package outbound bounds server-initiated network work — URL previews,
|
||||
// CardDAV syncs, IMAP test/check passes and manual signal checks — so an
|
||||
// authenticated user cannot open unbounded outbound connections, exhaust
|
||||
// file descriptors or saturate egress (review M-2).
|
||||
//
|
||||
// Two layers are shared by every caller:
|
||||
// - a global in-flight cap (defaultConcurrent), and
|
||||
// - a per-identity fixed-window rate (the caller supplies the limit/window,
|
||||
// since a preview is cheaper than an IMAP search).
|
||||
//
|
||||
// Exhaustion is immediate: previews degrade to no metadata and interactive
|
||||
// endpoints answer 429 with Retry-After. Nothing queues behind the budget.
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultConcurrent = 8
|
||||
|
||||
type window struct {
|
||||
count int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
type budget struct {
|
||||
mu sync.Mutex
|
||||
inFlight int
|
||||
max int
|
||||
keys map[string]*window
|
||||
}
|
||||
|
||||
var shared = &budget{max: defaultConcurrent, keys: map[string]*window{}}
|
||||
|
||||
// Reserve claims one outbound slot for key. The returned release MUST be
|
||||
// called when the work finishes. When ok is false, retry is a conservative
|
||||
// Retry-After duration.
|
||||
func Reserve(key string, limit int, windowDur time.Duration) (release func(), retry time.Duration, ok bool) {
|
||||
if key == "" {
|
||||
key = "anonymous"
|
||||
}
|
||||
shared.mu.Lock()
|
||||
defer shared.mu.Unlock()
|
||||
if shared.inFlight >= shared.max {
|
||||
return nil, time.Second, false
|
||||
}
|
||||
now := time.Now()
|
||||
if len(shared.keys) > 4096 {
|
||||
for k, w := range shared.keys {
|
||||
if now.After(w.reset) {
|
||||
delete(shared.keys, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
w := shared.keys[key]
|
||||
if w == nil || now.After(w.reset) {
|
||||
w = &window{reset: now.Add(windowDur)}
|
||||
shared.keys[key] = w
|
||||
}
|
||||
if limit > 0 && w.count >= limit {
|
||||
left := w.reset.Sub(now)
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
return nil, left.Truncate(time.Second) + time.Second, false
|
||||
}
|
||||
w.count++
|
||||
shared.inFlight++
|
||||
return func() {
|
||||
shared.mu.Lock()
|
||||
shared.inFlight--
|
||||
shared.mu.Unlock()
|
||||
}, 0, true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Review M-2: outbound work is bounded per identity and globally, and a
|
||||
// released slot is reusable.
|
||||
func TestReserveBoundsPerKeyAndGlobally(t *testing.T) {
|
||||
releaseA, retry, ok := Reserve("test-a", 1, time.Minute)
|
||||
if !ok || releaseA == nil {
|
||||
t.Fatalf("first reservation rejected (retry %v)", retry)
|
||||
}
|
||||
if _, _, ok := Reserve("test-a", 1, time.Minute); ok {
|
||||
t.Fatal("per-key limit did not reject the second reservation")
|
||||
}
|
||||
|
||||
// Fill the remaining global slots on distinct keys.
|
||||
releases := []func(){releaseA}
|
||||
for i := 0; i < defaultConcurrent-1; i++ {
|
||||
release, _, ok := Reserve("test-global-"+string(rune('a'+i)), 10, time.Minute)
|
||||
if !ok {
|
||||
t.Fatalf("global slot %d rejected below the cap", i)
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
if _, _, ok := Reserve("test-overflow", 10, time.Minute); ok {
|
||||
t.Fatal("global concurrency cap did not reject an extra reservation")
|
||||
}
|
||||
|
||||
// Release one slot and verify the global capacity is usable again.
|
||||
releases[0]()
|
||||
release, _, ok := Reserve("test-after-release", 1, time.Minute)
|
||||
if !ok {
|
||||
t.Fatal("released slot was not reusable")
|
||||
}
|
||||
release()
|
||||
for _, r := range releases[1:] {
|
||||
r()
|
||||
}
|
||||
}
|
||||
+10
-49
@@ -4,13 +4,13 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/andrew/go-computer/internal/netguard"
|
||||
"github.com/andrew/go-computer/internal/outbound"
|
||||
"golang.org/x/net/html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -72,60 +72,21 @@ var previewClient = &http.Client{
|
||||
// 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
|
||||
keyLimit = 20
|
||||
keyWindow = time.Minute
|
||||
// maxConcurrent mirrors outbound's global cap; kept for the budget tests
|
||||
// and documentation.
|
||||
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.
|
||||
// ok is false when either budget layer is exhausted. Preview shares the
|
||||
// process-wide outbound budget with CardDAV/IMAP/signal checks (review M-2),
|
||||
// while keeping its own more generous per-identity rate.
|
||||
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
|
||||
release, _, ok = outbound.Reserve(key, keyLimit, keyWindow)
|
||||
return release, ok
|
||||
}
|
||||
|
||||
// FetchPreview resolves metadata for raw on behalf of key (the requesting
|
||||
|
||||
Reference in New Issue
Block a user