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
358 lines
14 KiB
Go
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
|
|
}
|