hako/internal/server/server.go
Felipe M. 484b225af7
feat: add user administration API and UI
Add admin-only CRUD endpoints for user management under /api/v1/system/users
with full frontend implementation including create, edit, change password,
and delete operations with self-protection guards.

- Extend AuthDomain interface with ListUsers, GetUser, UpdateUser,
  UpdateUserPassword, DeleteUser methods
- Add List, Update, Delete methods to UserStore with shared scanUser helper
- Create UserHandler with 5 endpoints protected by admin middleware
- Complete Users.vue admin page with modals and error handling
- Make CreateUser accept role parameter for atomic user creation
- Fix auth middleware to use database role as source of truth instead of
  stale JWT claims, preventing privilege persistence after demotion
- Fix pre-existing gofmt issues in yt_dlp.go and config.go
2026-04-02 10:33:36 +02:00

358 lines
14 KiB
Go

package server
import (
"context"
"fmt"
"log/slog"
"net/http"
toolkitModel "git.nakama.town/fmartingr/gotoolkit/model"
"git.nakama.town/fmartingr/hako/internal/archival/archiver"
archivalDomain "git.nakama.town/fmartingr/hako/internal/archival/domain"
archivalRules "git.nakama.town/fmartingr/hako/internal/archival/rules"
archivalStore "git.nakama.town/fmartingr/hako/internal/archival/store"
"git.nakama.town/fmartingr/hako/internal/auth"
authDomain "git.nakama.town/fmartingr/hako/internal/auth/domain"
"git.nakama.town/fmartingr/hako/internal/auth/store"
"git.nakama.town/fmartingr/hako/internal/config"
"git.nakama.town/fmartingr/hako/internal/database"
"git.nakama.town/fmartingr/hako/internal/dependencies"
"git.nakama.town/fmartingr/hako/internal/extractors"
"git.nakama.town/fmartingr/hako/internal/jobs"
"git.nakama.town/fmartingr/hako/internal/model"
"git.nakama.town/fmartingr/hako/internal/server/handlers"
"git.nakama.town/fmartingr/hako/internal/server/middleware"
"git.nakama.town/fmartingr/hako/internal/storage"
"git.nakama.town/fmartingr/hako/webapp"
)
// HTTPServer wraps the HTTP server and routes
type HTTPServer struct {
logger *slog.Logger
server *http.Server
router *http.ServeMux
fs http.FileSystem
worker *jobs.Worker
config *config.Config
}
// Ensure HTTPServer implements model.Server interface
var _ toolkitModel.Server = (*HTTPServer)(nil)
// NewServer creates a new server instance
func NewServer(cfg *config.Config, logger *slog.Logger) (*HTTPServer, error) {
// Prepare webapp filesystem
fs, err := webapp.GetFilesystem()
if err != nil {
return nil, fmt.Errorf("failed to prepare webapp filesystem: %w", err)
}
// Connect to databases (read/write separation)
dbConnections, err := database.NewConnections(cfg.DatabaseReadURL, cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to databases: %w", err)
}
// Run migrations to initialize/update schema
if err := database.InitSchema(dbConnections); err != nil {
_ = dbConnections.Close()
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
// Create dependencies instance
deps := dependencies.NewDependencies(logger, dbConnections, cfg)
// Initialize store layer with read/write connections
userStore := store.NewUserStore(dbConnections.Read, dbConnections.Write)
linkStore := archivalStore.NewLinkStore(dbConnections.Read, dbConnections.Write)
archiveStore := archivalStore.NewArchiveStore(dbConnections.Read, dbConnections.Write)
archiveFileStore := archivalStore.NewArchiveFileStore(dbConnections.Read, dbConnections.Write)
categoryStore := archivalStore.NewCategoryStore(dbConnections.Read, dbConnections.Write)
linkCategoryStore := archivalStore.NewLinkCategoryStore(dbConnections.Read, dbConnections.Write)
archiverConfigStore := archivalStore.NewArchiverConfigStore(dbConnections.Read, dbConnections.Write)
settingsStore := archivalStore.NewSettingsStore(dbConnections.Read, dbConnections.Write)
// Initialize rule engine
// Load rules configuration from database
ctx := context.Background()
rulesConfig, err := settingsStore.GetRulesConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load rules configuration: %w", err)
}
ruleEngine := archivalRules.NewEngineWithLogger(rulesConfig, logger)
// Initialize JWT service
jwtService := auth.NewJWTService(cfg.JWTSecret, cfg.JWTExpiration)
// Initialize storage
stor := storage.NewLocalStorage(cfg.ArchiveStoragePath)
// Initialize archiver manager
archiverMgr := archiver.NewManager()
// Register obelisk archiver (default)
if err := archiverMgr.Register(archiver.NewObeliskExtractor()); err != nil {
return nil, fmt.Errorf("failed to register obelisk archiver: %w", err)
}
// Register direct download archiver (fallback)
if err := archiverMgr.Register(archiver.NewDirectDownloadExtractor()); err != nil {
return nil, fmt.Errorf("failed to register archiver: %w", err)
}
// Register thumbnail archiver
if err := archiverMgr.Register(archiver.NewThumbnailExtractor()); err != nil {
return nil, fmt.Errorf("failed to register thumbnail archiver: %w", err)
}
// Register yt-dlp archiver (optional - may be disabled when binary not available)
if err := archiverMgr.Register(archiver.NewYtDlpArchiver()); err != nil {
logger.Warn("Failed to register yt-dlp archiver", "error", err)
}
// Initialize extractor manager
extractorMgr := extractors.NewManager(logger)
if err := extractorMgr.Register(extractors.NewPDFExtractor()); err != nil {
// Log error but don't fail server startup - extractor will be marked as unavailable
logger.Error("Failed to register PDF extractor", "error", err)
}
if err := extractorMgr.Register(extractors.NewReadableExtractor()); err != nil {
// Log error but don't fail server startup - extractor will be marked as unavailable
logger.Error("Failed to register readable extractor", "error", err)
}
// Initialize job queue
queue := jobs.NewMemoryQueue()
// Set stores and other dependencies in dependencies
deps.LinkStore = linkStore
deps.ArchiveStore = archiveStore
deps.ArchiveFileStore = archiveFileStore
deps.LinkCategoryStore = linkCategoryStore
deps.CategoryStore = categoryStore
deps.ArchiverConfigStore = archiverConfigStore
deps.SettingsStore = settingsStore
deps.UserStore = userStore
deps.Storage = stor
deps.Queue = queue
deps.ArchiverMgr = archiverMgr
deps.ExtractorMgr = extractorMgr
deps.RuleEngine = ruleEngine
deps.JWTService = jwtService
// Initialize domains and set them in dependencies
deps.Domains().SetLinks(archivalDomain.NewLinkDomain(deps))
deps.Domains().SetArchives(archivalDomain.NewArchiveDomain(deps))
deps.Domains().SetCategories(archivalDomain.NewCategoryDomain(deps))
deps.Domains().SetAuth(authDomain.NewAuthDomain(deps))
// Initialize worker
worker := jobs.NewWorker(queue, archiverMgr, extractorMgr, deps.Domains().Archives(), archiveStore, archiveFileStore, linkStore, cfg.ArchiveStoragePath, logger)
// Initialize handlers
authHandler := handlers.NewAuthHandler(deps)
linkHandler := handlers.NewLinkHandler(deps, logger)
archiveHandler := handlers.NewArchiveHandler(deps)
categoryHandler := handlers.NewCategoryHandler(deps)
userHandler := handlers.NewUserHandler(deps)
// Initialize system handler (needed for extractors and archivers endpoints)
systemHandler := handlers.NewSystemHandler(func(ctx context.Context) (string, error) {
// Return hako application version (set via ldflags at build time)
return model.BuildVersion, nil
}, extractorMgr, archiverMgr, archiverConfigStore, settingsStore)
// Initialize middleware
// CORS middleware - handles cross-origin requests from Vite dev server
corsMiddleware := middleware.CORSMiddleware()
// Global auth middleware - sets context if token is present (doesn't block)
// Also verifies that the user exists in the database
globalAuthMiddleware := middleware.AuthMiddleware(jwtService, deps.UserStore)
// Require auth middleware - blocks if not authenticated
requireAuthMiddleware := middleware.RequireAuthMiddleware()
// Require admin middleware - blocks if not admin
requireAdminMiddleware := middleware.RequireAdminMiddleware()
srv := &HTTPServer{
logger: logger,
router: http.NewServeMux(),
fs: fs,
worker: worker,
config: cfg,
}
// Start background worker
go func() {
if err := worker.Start(context.Background()); err != nil {
logger.Error("Worker stopped", "error", err)
}
}()
// Create HTTP server with middleware chain
// Order: (Logging) → CORS → Auth → Routes
// Logging logs all requests (if enabled), CORS handles cross-origin requests, Auth sets user context if token is present
addr, err := cfg.GetBindAddress()
if err != nil {
return nil, fmt.Errorf("failed to get bind address: %w", err)
}
// Build middleware chain conditionally
handler := http.Handler(srv.router)
handler = globalAuthMiddleware(handler)
handler = corsMiddleware(handler)
// Add access logging middleware if enabled
if cfg.EnableAccessLog {
loggingMiddleware := middleware.LoggingMiddleware(logger)
handler = loggingMiddleware(handler)
}
srv.server = &http.Server{
Addr: addr,
Handler: handler,
}
// Set up API v1 routes
// Login is public, so we handle it separately
srv.router.HandleFunc("/api/v1/auth/login", authHandler.HandleLogin)
// Categories endpoint (public, read-only)
srv.router.HandleFunc("/api/v1/categories", categoryHandler.HandleListCategories)
// Admin-only routes (all system endpoints) - register first for longest match
adminRouter := http.NewServeMux()
adminRouter.HandleFunc("GET /system/extractors", systemHandler.HandleExtractors)
adminRouter.HandleFunc("GET /system/archivers", systemHandler.HandleArchivers)
adminRouter.HandleFunc("GET /system/archivers/{key}/config", systemHandler.HandleGetArchiverConfig)
adminRouter.HandleFunc("PUT /system/archivers/{key}/config", systemHandler.HandleSaveArchiverConfig)
adminRouter.HandleFunc("DELETE /system/archivers/{key}/config", systemHandler.HandleDeleteArchiverConfig)
adminRouter.HandleFunc("GET /system/rules/config", systemHandler.HandleGetRulesConfig)
adminRouter.HandleFunc("PUT /system/rules/config", systemHandler.HandleSaveRulesConfig)
adminRouter.HandleFunc("GET /system/users", userHandler.HandleListUsers)
adminRouter.HandleFunc("POST /system/users", userHandler.HandleCreateUser)
adminRouter.HandleFunc("PUT /system/users/{id}", userHandler.HandleUpdateUser)
adminRouter.HandleFunc("PUT /system/users/{id}/password", userHandler.HandleUpdatePassword)
adminRouter.HandleFunc("DELETE /system/users/{id}", userHandler.HandleDeleteUser)
// Apply require auth and admin middleware to admin routes
// The global auth middleware already set context if token was present
adminHandler := http.StripPrefix("/api/v1", adminRouter)
srv.router.Handle("/api/v1/system/", requireAuthMiddleware(requireAdminMiddleware(adminHandler)))
// Protected routes require authentication
protectedRouter := http.NewServeMux()
protectedRouter.HandleFunc("/auth/me", authHandler.HandleGetCurrentUser)
// Link routes
protectedRouter.HandleFunc("POST /links", linkHandler.HandleCreateLink)
protectedRouter.HandleFunc("GET /links", linkHandler.HandleListLinks)
protectedRouter.HandleFunc("GET /links/{id}", linkHandler.HandleGetLink)
protectedRouter.HandleFunc("DELETE /links/{id}", linkHandler.HandleDeleteLink)
// Archive routes
protectedRouter.HandleFunc("GET /links/{linkId}/archives", archiveHandler.HandleGetArchiveHistory)
protectedRouter.HandleFunc("POST /links/{linkId}/archives", archiveHandler.HandleReArchiveLink)
protectedRouter.HandleFunc("GET /archives/{archiveId}", archiveHandler.HandleGetArchive)
protectedRouter.HandleFunc("DELETE /archives/{archiveId}", archiveHandler.HandleDeleteArchive)
protectedRouter.HandleFunc("GET /archives/{archiveId}/files", archiveHandler.HandleGetArchiveFiles)
protectedRouter.HandleFunc("GET /archives/{archiveId}/files/{fileId}/download", archiveHandler.HandleDownloadFile)
// Apply require auth middleware to protected routes
// The global auth middleware already set context if token was present
protectedHandler := http.StripPrefix("/api/v1", protectedRouter)
srv.router.Handle("/api/v1/", requireAuthMiddleware(protectedHandler))
// System route (public) - uses handler that checks authentication
srv.router.HandleFunc("/system", systemHandler.HandleSystem)
// WebSocket route
srv.router.HandleFunc("/ws", srv.handleWebSocket)
// Static files and SPA fallback (serve last to catch all other routes)
fileServer := http.FileServer(fs)
srv.router.HandleFunc("/", srv.handleWebapp(fileServer))
return srv, nil
}
// IsEnabled implements the model.Server interface
func (s *HTTPServer) IsEnabled() bool {
return true
}
// Start starts the server - implements the model.Server interface
func (s *HTTPServer) Start(ctx context.Context) error {
addr := s.server.Addr
url, err := s.config.GetServerURL()
if err != nil {
// If we can't get the URL, log a warning but continue
s.logger.Warn("Failed to get server URL", "error", err)
s.logger.Info("Starting HTTP server", "address", addr)
} else {
s.logger.Info("Starting HTTP server", "url", url, "address", addr)
}
return s.server.ListenAndServe()
}
// Stop stops the server - implements the model.Server interface
func (s *HTTPServer) Stop(ctx context.Context) error {
s.logger.Info("Stopping HTTP server")
return s.server.Shutdown(ctx)
}
// handleWebSocket handles WebSocket connections
func (s *HTTPServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// WebSocket implementation will be added later
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"message":"WebSocket endpoint not implemented yet"}`))
}
// handleWebapp serves the webapp with SPA fallback support
func (s *HTTPServer) handleWebapp(fileServer http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if this is a request for a static asset (JS, CSS, images, etc.)
// These typically have file extensions or are in /assets/ directory
path := r.URL.Path
// Try to open the file to see if it exists
file, err := s.fs.Open(path)
if err == nil {
_ = file.Close()
// File exists, serve it
fileServer.ServeHTTP(w, r)
return
}
// Check if it's a request for a directory or a file with extension
// If it has an extension, it's likely a static file that doesn't exist (404)
if hasExtension(path) {
http.NotFound(w, r)
return
}
// For all other routes, serve index.html (SPA fallback)
// This allows Vue Router to handle client-side routing
indexFile, err := s.fs.Open("/index.html")
if err != nil {
http.NotFound(w, r)
return
}
defer func() { _ = indexFile.Close() }()
stat, err := indexFile.Stat()
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(w, r, "index.html", stat.ModTime(), indexFile)
}
}
// hasExtension checks if a path has a file extension
func hasExtension(path string) bool {
// Common static file extensions
extensions := []string{".js", ".css", ".json", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".map"}
for _, ext := range extensions {
if len(path) >= len(ext) && path[len(path)-len(ext):] == ext {
return true
}
}
return false
}