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.
203 lines
11 KiB
Go
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"
|
|
}
|