package main import ( "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" "syscall" "git.lohmar.co.uk/lexton-it/NextWks/core/admin" "git.lohmar.co.uk/lexton-it/NextWks/core/auth" "git.lohmar.co.uk/lexton-it/NextWks/core/config" "git.lohmar.co.uk/lexton-it/NextWks/core/db" "git.lohmar.co.uk/lexton-it/NextWks/core/ui" ) func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) // Config path: default to ./config.yaml for dev, override with -config for production configPath := flag.String("config", "./config.yaml", "path to configuration file") flag.Parse() logger.Info("starting Next Workspace (NextWks)", "config", *configPath) // Load configuration cfg, err := config.Load(*configPath) if err != nil { logger.Error("failed to load config", "error", err) os.Exit(1) } // Initialize database database, err := db.Initialize(cfg.Database.Path) if err != nil { logger.Error("failed to initialize database", "error", err) os.Exit(1) } defer database.Close() // Run schema migrations if err := database.Migrate(); err != nil { logger.Error("failed to run migrations", "error", err) os.Exit(1) } logger.Info("database initialized and migrated", "path", cfg.Database.Path) // Initialize admin components userStore := admin.NewUserStore(database.DB) syncWriter := admin.NewSyncWriter(cfg.Authelia.UsersDBPath, userStore) // Bootstrap: import existing Authelia users if this is a fresh start imported, err := syncWriter.Bootstrap() if err != nil { logger.Warn("bootstrap authelia users", "error", err) } else if imported > 0 { logger.Info("bootstrapped authelia users", "count", imported) } // Create admin handler adminHandler := admin.NewHandler(userStore, syncWriter, logger) // Initialize session store and OIDC auth sessionStore := auth.NewSessionStore(database.DB) // OIDC issuer: public-facing URL (via Zoraxy) for browser redirects // Falls back to authelia.host if not configured issuerURL := cfg.OIDC.IssuerURL if issuerURL == "" { issuerURL = cfg.Authelia.Host } oidcCfg := auth.OIDCConfig{ IssuerURL: issuerURL, ClientID: cfg.OIDC.ClientID, ClientSecret: cfg.OIDC.ClientSecret, RedirectURL: cfg.OIDC.RedirectURL, Domain: cfg.OIDC.Domain, } oidcHandler := auth.NewOIDCHandler(oidcCfg, sessionStore) // Initialize launcher UI handler // appDir is the directory containing config.yaml (and static/ subdir) appDir := filepath.Dir(*configPath) if appDir == "." { appDir = "./" } uiHandler := ui.NewHandler(appDir) // Setup HTTP router mux := http.NewServeMux() // --- Public endpoints --- mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) }) // --- OIDC auth routes (public) --- mux.HandleFunc("GET /auth/login", oidcHandler.LoginRedirect) mux.HandleFunc("GET /auth/callback", oidcHandler.Callback) mux.HandleFunc("POST /auth/callback", oidcHandler.Callback) mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) { // Clear session cookie http.SetCookie(w, &http.Cookie{ Name: "nextwks_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) http.Redirect(w, r, "/auth/login", http.StatusFound) }) // --- Workspace launcher (public, but OIDC-protected) --- sessionMiddleware := sessionStore.SessionMiddleware authGate := oidcHandler.AuthGateMiddleware uiHandler.RegisterRoutes(mux, authGate) _ = sessionMiddleware // Used for session-aware middleware in future // --- Admin routes (protected by bearer token) --- adminAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken) adminHandler.RegisterRoutes(mux, adminAuth) adminHandler.RegisterUIRoutes(mux, adminAuth) adminHandler.RegisterHTMXRoutes(mux, adminAuth) // --- OIDC config page — shows Authelia status --- mux.HandleFunc("GET /auth/status", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"provider":"Authelia","issuer":"%s","status":"configured"}`, cfg.Authelia.Host) }) // --- PWA Guide modal (HTMX fragment) --- mux.HandleFunc("GET /pwa-guide", func(w http.ResponseWriter, r *http.Request) { component := ui.PWAGuideModal() component.Render(r.Context(), w) }) // CORS middleware handler := corsMiddleware(mux) // Start server addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) server := &http.Server{ Addr: addr, Handler: handler, } // Graceful shutdown go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan logger.Info("shutting down server...") server.Close() }() logger.Info("server listening", "address", addr) logger.Info("workspace launcher", "url", fmt.Sprintf("http://%s/", addr)) logger.Info("admin panel", "url", fmt.Sprintf("http://%s/admin", addr)) logger.Info("auth status", "url", fmt.Sprintf("http://%s/auth/status", addr)) if err := server.ListenAndServe(); err != http.ErrServerClosed { logger.Error("server error", "error", err) os.Exit(1) } } // corsMiddleware adds CORS headers for frontend access. func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) }