185 lines
5.6 KiB
Go
185 lines
5.6 KiB
Go
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)
|
|
})
|
|
}
|