Files
pad/internal/server/server.go
T
xarmian a30655aa0e feat: add optional password authentication for web UI (#6)
When PAD_PASSWORD is set (env var) or password is configured in
~/.pad/config.toml, the server requires authentication:

Backend:
- SessionManager with HMAC-SHA256 signed cookies (7-day TTL)
- POST /api/v1/auth/login — validates password, sets session cookie
- GET /api/v1/auth/session — returns auth status (exempt from auth)
- POST /api/v1/auth/logout — destroys session, clears cookie
- PasswordAuth middleware gates all API/page requests
- API tokens still work independently (no change to CLI flow)
- Constant-time password comparison + 500ms delay on failure

Frontend:
- Login page at /login with password form and error handling
- Root layout checks auth status before loading app shell
- Global 401 handler in API client redirects to /login
- Login page renders without sidebar/app shell

When no password is configured, everything works exactly as before
(zero-friction localhost). This is a security requirement for any
deployment that exposes the server beyond localhost.
2026-03-28 11:36:10 -04:00

319 lines
9.0 KiB
Go

package server
import (
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/xarmian/pad/internal/events"
"github.com/xarmian/pad/internal/models"
"github.com/xarmian/pad/internal/store"
"github.com/xarmian/pad/internal/webhooks"
)
type Server struct {
store *store.Store
router *chi.Mux
webFS fs.FS // embedded web UI static files (optional)
events *events.Bus // real-time event bus (optional)
webhooks *webhooks.Dispatcher // webhook dispatcher (optional)
password string // optional password for web UI access
sessions *SessionManager // session manager (initialized when password is set)
}
func New(s *store.Store) *Server {
srv := &Server{store: s}
srv.setupRouter()
return srv
}
// SetPassword enables password authentication for the web UI.
func (s *Server) SetPassword(pw string) {
s.password = pw
s.sessions = NewSessionManager()
}
// SetEventBus attaches an event bus for real-time SSE streaming.
func (s *Server) SetEventBus(bus *events.Bus) {
s.events = bus
}
// SetWebhookDispatcher attaches a webhook dispatcher for outgoing notifications.
func (s *Server) SetWebhookDispatcher(d *webhooks.Dispatcher) {
s.webhooks = d
}
func (s *Server) setupRouter() {
r := chi.NewRouter()
// Middleware
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)
r.Use(chimiddleware.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:*", "http://127.0.0.1:*"},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(s.TokenAuth)
r.Use(s.PasswordAuth)
r.Use(jsonContentType)
// SSE endpoint (outside jsonContentType middleware)
r.Get("/api/v1/events", s.handleSSE)
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Get("/health", s.handleHealth)
// Auth endpoints (exempt from PasswordAuth middleware)
r.Route("/auth", func(r chi.Router) {
r.Get("/session", s.handleSessionCheck)
r.Post("/login", s.handleLogin)
r.Post("/logout", s.handleLogout)
})
// Templates
r.Get("/templates", s.handleListTemplates)
// Convention Library
r.Get("/convention-library", s.handleConventionLibrary)
// Playbook Library
r.Get("/playbook-library", s.handlePlaybookLibrary)
// Workspaces
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", s.handleListWorkspaces)
r.Post("/", s.handleCreateWorkspace)
r.Post("/import", s.handleImportWorkspace)
r.Route("/{slug}", func(r chi.Router) {
r.Get("/", s.handleGetWorkspace)
r.Patch("/", s.handleUpdateWorkspace)
r.Delete("/", s.handleDeleteWorkspace)
r.Get("/export", s.handleExportWorkspace)
// Activity (workspace level)
r.Get("/activity", s.handleListWorkspaceActivity)
// Documents (v1 — will be replaced by items in Phase 2)
r.Route("/documents", func(r chi.Router) {
r.Get("/", s.handleListDocuments)
r.Post("/", s.handleCreateDocument)
r.Route("/{docID}", func(r chi.Router) {
r.Get("/", s.handleGetDocument)
r.Patch("/", s.handleUpdateDocument)
r.Delete("/", s.handleDeleteDocument)
r.Post("/restore", s.handleRestoreDocument)
// Versions
r.Get("/versions", s.handleListVersions)
r.Get("/versions/{versionID}", s.handleGetVersion)
// Activity (document level)
r.Get("/activity", s.handleListDocumentActivity)
})
})
// Collections (v2)
r.Route("/collections", func(r chi.Router) {
r.Get("/", s.handleListCollections)
r.Post("/", s.handleCreateCollection)
r.Route("/{collSlug}", func(r chi.Router) {
r.Get("/", s.handleGetCollection)
r.Patch("/", s.handleUpdateCollection)
r.Delete("/", s.handleDeleteCollection)
// Items within collection
r.Get("/items", s.handleListCollectionItems)
r.Post("/items", s.handleCreateItem)
// Saved views within collection
r.Get("/views", s.handleListViews)
r.Post("/views", s.handleCreateView)
r.Route("/views/{viewID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateView)
r.Delete("/", s.handleDeleteView)
})
})
})
// Phases progress
r.Get("/phases-progress", s.handlePhasesProgress)
// Items (cross-collection, v2)
r.Get("/items", s.handleListItems)
r.Route("/items/{itemSlug}", func(r chi.Router) {
r.Get("/", s.handleGetItem)
r.Patch("/", s.handleUpdateItem)
r.Delete("/", s.handleDeleteItem)
r.Post("/restore", s.handleRestoreItem)
r.Post("/move", s.handleMoveItem)
r.Get("/versions", s.handleListItemVersions)
r.Post("/versions/{versionID}/restore", s.handleRestoreItemVersion)
r.Get("/links", s.handleGetItemLinks)
r.Post("/links", s.handleCreateItemLink)
r.Get("/comments", s.handleListComments)
r.Post("/comments", s.handleCreateComment)
r.Get("/tasks", s.handleGetItemTasks)
})
// Links (v2)
r.Delete("/links/{linkID}", s.handleDeleteItemLink)
// Comments (v2)
r.Delete("/comments/{commentID}", s.handleDeleteComment)
// Webhooks
r.Route("/webhooks", func(r chi.Router) {
r.Get("/", s.handleListWebhooks)
r.Post("/", s.handleCreateWebhook)
r.Route("/{webhookID}", func(r chi.Router) {
r.Delete("/", s.handleDeleteWebhook)
r.Post("/test", s.handleTestWebhook)
})
})
// API Tokens
r.Route("/tokens", func(r chi.Router) {
r.Get("/", s.handleListTokens)
r.Post("/", s.handleCreateToken)
r.Delete("/{tokenID}", s.handleDeleteToken)
})
// Dashboard (v2)
r.Get("/dashboard", s.handleGetDashboard)
})
})
// Search
r.Get("/search", s.handleSearch)
})
s.router = r
}
// SetWebUI sets the embedded web UI filesystem for serving the SPA.
func (s *Server) SetWebUI(fsys fs.FS) {
s.webFS = fsys
s.router.Handle("/*", s.spaHandler())
}
func (s *Server) spaHandler() http.Handler {
fileServer := http.FileServer(http.FS(s.webFS))
indexHTML, _ := fs.ReadFile(s.webFS, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasPrefix(path, "/api/") {
http.NotFound(w, r)
return
}
cleanPath := strings.TrimPrefix(path, "/")
if cleanPath != "" {
if _, err := fs.Stat(s.webFS, cleanPath); err == nil {
if strings.Contains(path, "/immutable/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
fileServer.ServeHTTP(w, r)
return
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(http.StatusOK)
w.Write(indexHTML)
})
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
func (s *Server) ListenAndServe(addr string) error {
log.Printf("Pad server listening on %s", addr)
return http.ListenAndServe(addr, s.router)
}
// --- helpers ---
func jsonContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(r.URL.Path) >= 7 && r.URL.Path[:7] == "/api/v1" {
w.Header().Set("Content-Type", "application/json")
}
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("Error encoding JSON: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]interface{}{
"error": map[string]string{
"code": code,
"message": message,
},
})
}
func decodeJSON(r *http.Request, v interface{}) error {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
return nil
}
// getWorkspaceID resolves workspace slug to ID.
func (s *Server) getWorkspaceID(w http.ResponseWriter, r *http.Request) (string, bool) {
slug := chi.URLParam(r, "slug")
ws, err := s.store.GetWorkspaceBySlug(slug)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return "", false
}
if ws == nil {
writeError(w, http.StatusNotFound, "not_found", "Workspace not found")
return "", false
}
return ws.ID, true
}
// getWorkspaceDocument resolves workspace slug and document ID from URL params.
func (s *Server) getWorkspaceDocument(w http.ResponseWriter, r *http.Request) (string, *models.Document, bool) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return "", nil, false
}
docID := chi.URLParam(r, "docID")
doc, err := s.store.GetDocument(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return "", nil, false
}
if doc == nil || doc.WorkspaceID != workspaceID {
writeError(w, http.StatusNotFound, "not_found", "Document not found")
return "", nil, false
}
return workspaceID, doc, true
}