package api import ( "context" "encoding/json" "errors" "io/fs" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/collab" "github.com/sandrodach/mindsift/internal/config" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" "github.com/sandrodach/mindsift/internal/ws" ) type Server struct { cfg *config.Config store *storage.Store wsHub *ws.Hub staticFS fs.FS leases *collab.LeaseManager // block leases and open sessions of notes notifier *collab.Notifier // wakes long-poll requests on note changes demo demoState // periodic reset in demo mode (demo.go) streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) loginGuard *loginGuard // brute-force protection (loginguard.go) voice *voiceService // self-hosted speech recognition (voice.go) // Replace the delivery of bundled notifications in tests (notify.go) notifyEmailHook func(to, subject, text, html string) error notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error notifyWebhookHook func(webhookURL, heading string, items []notifyItem) error notifyPushHook func(userID string, msg pushMessage) error pushHTTPClient *http.Client // tests: reach a local push service push pushKeys } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { srv := &Server{ cfg: cfg, store: store, wsHub: wsHub, staticFS: staticFS, leases: collab.NewLeaseManager(nil), notifier: collab.NewNotifier(), } srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection) srv.voice = newVoiceService(cfg.Speech.MaxParallel) if srv.voiceMode() == "server" { go srv.startVoiceCheck() } wsHub.SetRoomAuthorizer(srv.canJoinRoom) wsHub.SetOriginCheck(srv.streamOriginAllowed) // Next occurrences of repeating tasks appear live on every connected client store.OnTaskCreated = func(th *domain.Thought) { srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th) } go srv.startCompaction() if cfg.DemoMode && cfg.DemoResetMinutes > 0 { go srv.startDemoResetTicker() } if !cfg.DemoMode { go srv.startAlarmTicker() // the demo sends nothing (FLAW-33) go srv.startNotificationJobs() go srv.startBackupScheduler() } return srv } // canJoinRoom allows realtime rooms only to users with read access: "doc:" for notes, otherwise a shared list ID. func (s *Server) canJoinRoom(userID, room string) bool { if docID, ok := strings.CutPrefix(room, "doc:"); ok { return s.store.DocRole(userID, docID) != "" } _, err := s.store.GetListByID(userID, room) return err == nil } // taskAudience returns everyone who can see a task: its owner plus all users of its task list. func (s *Server) taskAudience(th *domain.Thought) []string { if th == nil { return nil } return append(s.store.GetTaskListUserIDs(th.TaskListID), th.UserID) } func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(s.clientIPMiddleware) r.Use(s.securityHeaders) r.Use(s.bodyLimit) r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) // Browsers of other sites may only call the API when no BASE_URL is set (auth runs over the // Authorization header, never cookies, so credentials are not allowed either way) allowedOrigins := []string{"*"} if s.cfg.BaseURLSet { allowedOrigins = []string{strings.TrimRight(s.cfg.BaseURL, "/")} } r.Use(cors.Handler(cors.Options{ AllowedOrigins: allowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"}, ExposedHeaders: []string{"Link", "X-Session-Token"}, AllowCredentials: false, MaxAge: 300, })) // Public Health, Setup & Auth Routes r.Get("/api/v1/health", s.handleHealth) r.Get("/api/v1/auth/status", s.handleAuthStatus) r.Get("/api/v1/legal/{type}", s.handleLegalText) r.Post("/api/v1/auth/setup", s.handleFirstBootSetup) r.Post("/api/v1/auth/register", s.handleRegister) r.Post("/api/v1/auth/login", s.handleLogin) r.Post("/api/v1/auth/quickstart", s.handleQuickStart) r.Post("/api/v1/auth/forgot-password", s.handleForgotPassword) r.Post("/api/v1/auth/reset-password", s.handleResetPassword) r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest) r.Get("/api/v1/ws", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket) r.Get("/api/v1/events", s.handleSSE) // Authenticated API Routes r.Group(func(r chi.Router) { r.Use(s.authMiddleware) r.Get("/api/v1/auth/me", s.handleMe) r.Post("/api/v1/events/rooms", s.handleSSERooms) r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket) r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe) r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia) r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch) r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask) r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask) r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract) r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments) r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge) r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview)) r.Get("/api/v1/link-preview/{key}/{kind}", s.handleLinkPreviewPicture) r.Get("/api/v1/auth/sessions", s.handleListSessions) r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession)) r.Post("/api/v1/auth/logout", s.handleLogout) r.Post("/api/v1/auth/revoke-all", s.notInDemo(s.handleRevokeAllSessions)) r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings) r.Post("/api/v1/auth/alarms/test", s.notInDemo(s.handleTestUserAlarm)) r.Get("/api/v1/users/directory", s.handleGetUsersDirectory) r.Get("/api/v1/collaborators", s.handleGetCollaborators) r.Get("/api/v1/stats", s.handleStats) r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/export", s.handleExport) r.Get("/api/v1/backups", s.handleListMyBackups) r.Get("/api/v1/backups/{name}", s.handleDownloadMyBackup) r.Post("/api/v1/backups/{name}/restore", s.handleRestoreMyBackup) r.Get("/api/v1/export/markdown", s.handleExportMarkdown) r.Post("/api/v1/import/mindsift", s.handleImportMindSift) r.Delete("/api/v1/user/data", s.handlePurgeOwnData) r.Delete("/api/v1/user/account", s.notInDemo(s.handleDeleteOwnAccount)) r.Post("/api/v1/import/obsidian", s.handleImportObsidian) r.Get("/api/v1/themes", s.handleGetThemes) r.Post("/api/v1/themes", s.handleSaveTheme) r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme) // Instant Capture & Thoughts r.Post("/api/v1/inbox", s.handleCaptureInbox) r.Get("/api/v1/thoughts", s.handleGetThoughts) r.Get("/api/v1/thoughts/{id}", s.handleGetThought) r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought) r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought) r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded) // Tinder-Style Triage & Tasks r.Get("/api/v1/triage/deck", s.handleGetTriageDeck) r.Patch("/api/v1/triage/{id}", s.handleTriageAction) r.Get("/api/v1/tasks", s.handleGetTasks) r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask) r.Post("/api/v1/tasks/reorder", s.handleReorderTasks) r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks) // Task Lists & Shares r.Get("/api/v1/task-lists", s.handleGetTaskLists) r.Post("/api/v1/task-lists", s.handleCreateTaskList) r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList) r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList) r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares) r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare) r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare) // Workspace Documents & Folders r.Get("/api/v1/folders", s.handleGetFolders) r.Post("/api/v1/folders", s.handleCreateFolder) r.Post("/api/v1/folders/move", s.handleMoveFolder) r.Delete("/api/v1/folders", s.handleDeleteFolder) r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders) r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder) r.Get("/api/v1/workspace/folders/shared/{id}", s.handleGetSharedFolderByID) r.Delete("/api/v1/workspace/folders/shared/{id}", s.handleDeleteSharedFolder) r.Put("/api/v1/workspace/folders/shared/{id}/mount", s.handleUpdateFolderMount) r.Get("/api/v1/workspace/folders/shared/{id}/shares", s.handleGetFolderShares) r.Post("/api/v1/workspace/folders/shared/{id}/shares", s.handleCreateFolderShare) r.Delete("/api/v1/workspace/folders/shared/{id}/shares/{shareId}", s.handleDeleteFolderShare) // Workspace Tags & Pages r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags) r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails) r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote) r.Get("/api/v1/journal", s.handleGetJournal) r.Get("/api/v1/push/key", s.handlePushKey) r.Get("/api/v1/push/devices", s.handlePushDevices) r.Post("/api/v1/push/subscribe", s.handlePushSubscribe) r.Post("/api/v1/push/unsubscribe", s.handlePushUnsubscribe) r.Post("/api/v1/push/test", s.handlePushTest) r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay) r.Get("/api/v1/documents", s.handleGetDocuments) r.Post("/api/v1/documents", s.handleCreateDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) r.Get("/api/v1/documents/trash", s.handleGetTrash) r.Delete("/api/v1/documents/trash", s.handleEmptyTrash) r.Post("/api/v1/documents/{id}/restore", s.handleRestoreDocument) r.Get("/api/v1/documents/{id}/backlinks", s.handleGetBacklinks) r.Delete("/api/v1/documents/{id}/permanent", s.handlePurgeDocument) r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask) // Collaborative note editing (operation log) r.Get("/api/v1/docs/{id}/snapshot", s.handleDocSnapshot) r.Post("/api/v1/docs/{id}/sync", s.handleDocSync) r.Get("/api/v1/docs/{id}/changes", s.handleDocChanges) r.Post("/api/v1/docs/{id}/leave", s.handleDocLeave) // Document Sharing & Collaboration r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares) r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare) r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare) // Shared Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) r.Post("/api/v1/lists", s.handleCreateList) r.Get("/api/v1/lists/{id}", s.handleGetListByID) r.Delete("/api/v1/lists/{id}", s.handleDeleteList) r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares) r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare) r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare) r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem) r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems) // Groups (user-managed; stored as teams) r.Get("/api/v1/teams", s.handleGetUserTeams) r.Post("/api/v1/teams", s.handleCreateTeamDirect) r.Put("/api/v1/teams/{id}", s.handleUpdateTeam) r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam) r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember) r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole) r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember) // API Keys Management r.Get("/api/v1/keys", s.handleGetAPIKeys) r.Post("/api/v1/keys", s.handleCreateAPIKey) r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey) // Staff Routes (Server-Admin) r.Group(func(r chi.Router) { r.Use(s.staffMiddleware) r.Get("/api/v1/admin/users", s.handleAdminGetUsers) r.Post("/api/v1/admin/users", s.notInDemo(s.handleAdminCreateUser)) r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser)) r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword)) r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser)) r.Get("/api/v1/admin/invites", s.handleAdminGetInvites) r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite) r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite) }) // Server-Admin Strictly Protected Routes r.Group(func(r chi.Router) { r.Use(s.adminMiddleware) r.Get("/api/v1/admin/settings", s.handleAdminGetSettings) r.Get("/api/v1/admin/backups", s.handleAdminListBackups) r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup)) r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup) r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings)) r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact) r.Get("/api/v1/admin/voice", s.handleAdminVoiceStatus) // Organizations decide who can see whom; only server admins manage them r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations) r.Post("/api/v1/admin/organizations", s.handleAdminCreateOrganization) r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization) r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization) r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization) r.Post("/api/v1/admin/smtp/test", s.notInDemo(s.handleAdminTestSMTP)) }) }) // Embedded Frontend SPA Handler if s.staticFS != nil { fileServer := http.FileServer(http.FS(s.staticFS)) r.Get("/*", func(w http.ResponseWriter, req *http.Request) { path := strings.TrimPrefix(req.URL.Path, "/") if path == "" { path = "index.html" } if _, err := fs.Stat(s.staticFS, path); err != nil { if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") { http.NotFound(w, req) return } req.URL.Path = "/" path = "index.html" } if path == "index.html" || path == "sw.js" || path == "manifest.json" { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") } if path == "index.html" { if data, err := fs.ReadFile(s.staticFS, "index.html"); err == nil { scheme := "http" if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" } host := req.Header.Get("X-Forwarded-Host") if host == "" { host = req.Host } if host != "" { origin := scheme + "://" + host htmlStr := string(data) htmlStr = strings.ReplaceAll(htmlStr, `content="/og-image.png"`, `content="`+origin+`/og-image.png"`) htmlStr = strings.ReplaceAll(htmlStr, `property="og:url" content="/"`, `property="og:url" content="`+origin+`/"`) data = []byte(htmlStr) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) return } } fileServer.ServeHTTP(w, req) }) } return r } // --- HELPERS & MIDDLEWARE --- func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } // writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status. func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) { if errors.Is(err, storage.ErrTaskListReadOnly) || errors.Is(err, storage.ErrAssigneeNoAccess) { writeError(w, http.StatusForbidden, err.Error()) return } writeError(w, fallbackStatus, err.Error()) } func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Only log API requests (/api/...) if !strings.HasPrefix(r.URL.Path, "/api") { next.ServeHTTP(w, r) return } start := time.Now() ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) next.ServeHTTP(ww, r) duration := time.Since(start) durationMs := float64(duration.Microseconds()) / 1000.0 status := ww.Status() if status == 0 { status = http.StatusOK } method := r.Method path := r.URL.Path if status >= 500 { slog.Error("HTTP request failed", "method", method, "path", path, "status", status, "duration_ms", durationMs, ) } else if status >= 400 { slog.Warn("HTTP request warning", "method", method, "path", path, "status", status, "duration_ms", durationMs, ) } else { slog.Debug("HTTP request completed", "method", method, "path", path, "status", status, "duration_ms", durationMs, ) } }) } // authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are // checked against the revocation list and token_version; viaAPIKey tells the caller which it was. // authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's // scopes for API keys. func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) { token = strings.TrimSpace(token) if token == "" { return nil, nil } if strings.HasPrefix(token, "mindsift_sec_") { u, scopes, err := s.store.ValidateAPIKey(token) if err != nil || u == nil || !u.IsActive { return nil, nil } return u, scopes } claims, err := auth.ValidateToken(s.cfg.JWTSecret, token) if err != nil || claims == nil { return nil, nil } u, err := s.store.GetUserByID(claims.UserID) if err != nil || u == nil || !u.IsActive { return nil, nil } if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion { return nil, nil } // Logged out, ended from the session list or idle for 30 days if !s.store.SessionActive(claims.ID, u.ID) { return nil, nil } return u, nil } type apiKeyScopesKey struct{} // requireDeliveryScope: API keys need the scope for the target they deliver to; sessions may do all. // target: "inbox"/"triage", "task", "list_item". func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool { scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string) if !isKey { return true } need := storage.ScopeTriage switch target { case "task": need = storage.ScopeTask case "list_item": need = storage.ScopeList case "document": need = "" // notes cannot be created with a key } for _, sc := range scopes { if need != "" && sc == need { return true } } writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion") return false } // apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest // authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope). // Everything else needs a login. func apiKeyAllowed(r *http.Request) bool { return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox" } func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := "" if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { token = strings.TrimPrefix(authHeader, "Bearer ") } else { token = r.Header.Get("X-API-Key") } user, keyScopes := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden") return } ctx := auth.WithUserID(r.Context(), user.ID) if keyScopes == nil { if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { ctx = withSession(ctx, claims.ID) s.renewSessionToken(w, user, claims, false) } } if keyScopes != nil { if !apiKeyAllowed(r) { writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)") return } ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes) } next.ServeHTTP(w, r.WithContext(ctx)) }) } func (s *Server) staffMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "Nicht autorisiert") return } user, err := s.store.GetUserByID(userID) if err != nil || user == nil || user.Role != "admin" { writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich") return } next.ServeHTTP(w, r) }) } func (s *Server) adminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "Nicht autorisiert") return } user, err := s.store.GetUserByID(userID) if err != nil || user == nil || user.Role != "admin" { writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich") return } next.ServeHTTP(w, r) }) }