Files
brain/internal/http/server.go
T
Andrew S Erwin 5eab58f99c
release gate / check (push) Failing after 6m36s
Add trust features: history, trash, export, and collection sharing
Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
2026-10-19 23:07:38 -05:00

194 lines
10 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: 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"
}