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 }