package httpserver import ( "embed" "html/template" "log/slog" "net/http" "os" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/realtime" "github.com/tim/cairnquire/apps/server/internal/store" "github.com/tim/cairnquire/apps/server/internal/sync" ) //go:embed templates/*.gohtml static/* var assets embed.FS type Dependencies struct { Config config.Config Logger *slog.Logger Documents *docs.Service Repository *docs.Repository ContentStore *store.ContentStore Hub *realtime.Hub SyncService *sync.Service SyncRepo *sync.Repository Auth *auth.Service } type Server struct { config config.Config logger *slog.Logger documents *docs.Service repository *docs.Repository contentStore *store.ContentStore hub *realtime.Hub syncService *sync.Service syncRepo *sync.Repository auth *auth.Service authLimiter *rateLimiter templates *template.Template webEnabled bool } func New(deps Dependencies) (http.Handler, error) { templates, err := template.New("").Funcs(template.FuncMap{ "trimMd": func(path string) string { return strings.TrimSuffix(path, ".md") }, "sub": func(a, b int) int { return a - b }, }).ParseFS(assets, "templates/*.gohtml") if err != nil { return nil, err } server := &Server{ config: deps.Config, logger: deps.Logger, documents: deps.Documents, repository: deps.Repository, contentStore: deps.ContentStore, hub: deps.Hub, syncService: deps.SyncService, syncRepo: deps.SyncRepo, auth: deps.Auth, authLimiter: newRateLimiter(5, time.Minute), templates: templates, } if _, err := os.Stat(deps.Config.Web.DistDir); err == nil { server.webEnabled = true } router := chi.NewRouter() router.Use(middleware.RequestID) router.Use(middleware.RealIP) router.Use(middleware.Recoverer) router.Use(server.timeoutExceptWebSocket(30 * time.Second)) router.Use(server.requestLogger) router.Use(server.securityHeaders) router.Use(server.authMiddleware) router.Get("/", server.handleIndex) router.Get("/login", server.handleLoginPage) router.Get("/account", server.handleAccountPage) router.Get("/device/verify", server.handleDeviceVerifyPage) router.Get("/health", server.handleHealth) router.Get("/sw.js", server.handleServiceWorker) router.Get("/ws", server.handleWebSocket) router.Get("/api/auth/me", server.handleAuthMe) router.Post("/api/auth/register/password", server.handlePasswordRegister) router.Post("/api/auth/login/password", server.handlePasswordLogin) router.Post("/api/auth/logout", server.handleLogout) router.Post("/api/auth/password/change", server.handleChangePassword) router.Delete("/api/auth/account", server.handleDeleteAccount) router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin) router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish) router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin) router.Post("/api/auth/passkeys/login/finish", server.handlePasskeyLoginFinish) router.Get("/api/tokens", server.handleListAPITokens) router.Post("/api/tokens", server.handleCreateAPIToken) router.Delete("/api/tokens/{id}", server.handleRevokeAPIToken) router.Post("/api/device/start", server.handleDeviceStart) router.Post("/api/device/approve", server.handleDeviceApprove) router.Post("/api/device/token", server.handleDeviceToken) router.Get("/api/device/verify", server.handleDeviceVerify) router.Post("/api/admin/login", server.handleAdminLogin) router.Get("/api/admin/users", server.handleAdminUsers) router.Post("/api/admin/users", server.handleAdminCreateUser) router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole) router.Get("/api/admin/auth-users", server.handleAdminAuthUsers) router.Get("/api/admin/workspace", server.handleAdminWorkspace) router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync) router.Get("/docs", server.handleDocsIndex) router.Get("/docs/*", server.handleDocument) router.Get("/api/documents", server.handleDocuments) router.Post("/api/documents/*", server.handleDocumentSave) router.Get("/api/search", server.handleSearch) router.Post("/api/uploads", server.handleUpload) router.Get("/attachments/{hash}", server.handleAttachment) // Sync protocol endpoints router.Post("/api/sync/init", server.handleSyncInit) router.Post("/api/sync/delta", server.handleSyncDelta) router.Post("/api/sync/resolve", server.handleSyncResolve) router.Get("/api/content/{hash}", server.handleContentFetch) router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static")))) if server.webEnabled { router.Get("/app", server.handleAppRedirect) router.Handle("/app/*", http.StripPrefix("/app/", server.appFileServer())) } return router, nil } func (s *Server) handleAppRedirect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/app/", http.StatusMovedPermanently) } func mustSub(path string) http.FileSystem { sub, err := fsSub(path) if err != nil { panic(err) } return http.FS(sub) }