Files
cairnquire/apps/server/internal/httpserver/server.go
2026-06-16 17:05:15 -04:00

194 lines
7.5 KiB
Go

package httpserver
import (
"context"
"embed"
"fmt"
"html/template"
"log/slog"
"net/http"
"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/collaboration"
"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
Collaboration *collaboration.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
collaboration *collaboration.Service
authLimiter *rateLimiter
templates *template.Template
devUserID string
}
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,
collaboration: deps.Collaboration,
authLimiter: newRateLimiter(5, time.Minute),
templates: templates,
}
if deps.Config.DevMode {
devUser, err := deps.Auth.EnsureDevUser(context.Background())
if err != nil {
return nil, fmt.Errorf("ensure dev user: %w", err)
}
server.devUserID = devUser.ID
deps.Logger.Info("dev mode enabled", "dev_user_id", devUser.ID)
}
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.setupMiddleware)
router.Use(server.authMiddleware)
router.Get("/", server.handleIndex)
router.Get("/setup", server.handleSetupPage)
router.Get("/login", server.handleLoginPage)
router.Get("/password-reset", server.handlePasswordResetPage)
router.Get("/account", server.handleAccountPage)
router.Get("/account/tokens/new", server.handleTokenCreatePage)
router.Get("/permissions", server.handlePermissionsPage)
router.Post("/permissions", server.handlePermissionsForm)
router.Get("/device/verify", server.handleDeviceVerifyPage)
router.Get("/health", server.handleHealth)
router.Get("/healthz", server.handleHealthz)
router.Get("/readyz", server.handleReadyz)
router.Get("/sw.js", server.handleServiceWorker)
router.Get("/ws", server.handleWebSocket)
router.Get("/api/auth/me", server.handleAuthMe)
router.Post("/api/setup", server.handleSetup)
router.Post("/api/auth/register/password", server.handlePasswordRegister)
router.Post("/api/auth/login/password", server.handlePasswordLogin)
router.Post("/api/auth/login/dev", server.handleDevLogin)
router.Post("/api/auth/password/reset", server.handlePasswordReset)
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/me/register/begin", server.handleCurrentUserPasskeyRegisterBegin)
router.Post("/api/auth/passkeys/me/register/finish", server.handleCurrentUserPasskeyRegisterFinish)
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.Patch("/api/admin/auth-users", server.handleAdminUpdateAuthUsers)
router.Post("/api/admin/auth-users/{id}/password-reset", server.handleAdminSendPasswordReset)
router.Get("/api/admin/settings", server.handleAdminSettings)
router.Patch("/api/admin/settings", server.handleAdminSettingsUpdate)
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.handleDocumentMutation)
router.Get("/api/search", server.handleSearch)
router.Get("/api/permissions", server.handlePermissionsAPI)
router.Patch("/api/permissions", server.handlePermissionsAPI)
router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList)
router.Get("/attachments/{hash}", server.handleAttachment)
// Collaboration endpoints
router.Get("/api/comments", server.handleListComments)
router.Post("/api/comments", server.handleCreateComment)
router.Post("/api/comments/{id}/resolve", server.handleResolveComment)
router.Get("/api/notifications", server.handleListNotifications)
router.Post("/api/notifications/{id}/read", server.handleMarkNotificationRead)
router.Post("/api/notifications/read-all", server.handleMarkAllNotificationsRead)
router.Get("/api/notifications/bell", server.handleNotificationBell)
router.Post("/api/watch/document", server.handleWatchDocument)
router.Post("/api/watch/folder", server.handleWatchFolder)
router.Delete("/api/watch/document", server.handleUnwatchDocument)
router.Delete("/api/watch/folder", server.handleUnwatchFolder)
router.Get("/api/watch/status", server.handleIsWatching)
// 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"))))
return router, nil
}
func mustSub(path string) http.FileSystem {
sub, err := fsSub(path)
if err != nil {
panic(err)
}
return http.FS(sub)
}