Files
brain/internal/http/server.go
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
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.
2026-10-20 00:13:30 -05:00

203 lines
11 KiB
Go

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}/backlinks", authed(handlers.HandlePagesBacklinks))
mux.HandleFunc("GET /api/pages/{id}/revisions", authed(handlers.HandlePageRevisionsList))
mux.HandleFunc("GET /api/pages/{id}/revisions/{revisionId}", authed(handlers.HandlePageRevisionsGet))
mux.HandleFunc("POST /api/pages/{id}/revisions/{revisionId}/restore", authed(handlers.HandlePageRevisionsRestore))
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
// Trust phase: recoverable deletions and structured export.
mux.HandleFunc("GET /api/trash", authed(handlers.HandleTrashList))
mux.HandleFunc("POST /api/trash/{id}/restore", authed(handlers.HandleTrashRestore))
mux.HandleFunc("DELETE /api/trash/{id}", authed(handlers.HandleTrashPurge))
mux.HandleFunc("GET /api/export", authed(handlers.HandleExport))
// 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}/share", authed(handlers.HandleCollectionShare))
mux.HandleFunc("DELETE /api/collections/{id}/share/{userId}", authed(handlers.HandleCollectionUnshare))
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/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(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),
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"
}