mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-10 15:59:58 -06:00
When users click logout, they are now redirected to the SSO provider's logout endpoint to ensure complete session termination. This prevents users from remaining logged in at the provider level after logging out of the application. Changes: - Add LogoutURL configuration for OAuth providers (Google, GitHub, GitLab) - Implement GetLogoutURL method with post-logout redirect parameter - Update HandleLogout to redirect to SSO logout when configured - Add ACKIFY_OAUTH_LOGOUT_URL environment variable for custom providers - Add tests for both local and SSO logout scenarios
295 lines
8.2 KiB
Go
295 lines
8.2 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/btouchard/ackify-ce/internal/application/services"
|
|
"github.com/btouchard/ackify-ce/internal/infrastructure/auth"
|
|
"github.com/btouchard/ackify-ce/internal/infrastructure/config"
|
|
"github.com/btouchard/ackify-ce/internal/infrastructure/database"
|
|
"github.com/btouchard/ackify-ce/internal/infrastructure/i18n"
|
|
"github.com/btouchard/ackify-ce/internal/presentation/handlers"
|
|
"github.com/btouchard/ackify-ce/pkg/crypto"
|
|
)
|
|
|
|
type Server struct {
|
|
httpServer *http.Server
|
|
db *sql.DB
|
|
router *chi.Mux
|
|
templates *template.Template
|
|
baseURL string
|
|
adminEmails []string
|
|
authService *auth.OauthService
|
|
}
|
|
|
|
func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
|
|
db, tmpl, signer, i18nService, err := initInfrastructure(ctx, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize infrastructure: %w", err)
|
|
}
|
|
|
|
authService := auth.NewOAuthService(auth.Config{
|
|
BaseURL: cfg.App.BaseURL,
|
|
ClientID: cfg.OAuth.ClientID,
|
|
ClientSecret: cfg.OAuth.ClientSecret,
|
|
AuthURL: cfg.OAuth.AuthURL,
|
|
TokenURL: cfg.OAuth.TokenURL,
|
|
UserInfoURL: cfg.OAuth.UserInfoURL,
|
|
LogoutURL: cfg.OAuth.LogoutURL,
|
|
Scopes: cfg.OAuth.Scopes,
|
|
AllowedDomain: cfg.OAuth.AllowedDomain,
|
|
CookieSecret: cfg.OAuth.CookieSecret,
|
|
SecureCookies: cfg.App.SecureCookies,
|
|
})
|
|
|
|
signatureRepo := database.NewSignatureRepository(db)
|
|
signatureService := services.NewSignatureService(signatureRepo, signer)
|
|
|
|
authHandlers := handlers.NewAuthHandlers(authService, cfg.App.BaseURL)
|
|
authMiddleware := handlers.NewAuthMiddleware(authService, cfg.App.BaseURL)
|
|
signatureHandlers := handlers.NewSignatureHandlers(signatureService, authService, tmpl, cfg.App.BaseURL, cfg.App.Organisation, cfg.App.AdminEmails)
|
|
badgeHandler := handlers.NewBadgeHandler(signatureService)
|
|
oembedHandler := handlers.NewOEmbedHandler(signatureService, tmpl, cfg.App.BaseURL, cfg.App.Organisation)
|
|
healthHandler := handlers.NewHealthHandler()
|
|
langHandlers := handlers.NewLangHandlers(cfg.App.SecureCookies)
|
|
|
|
router := setupRouter(authHandlers, authMiddleware, signatureHandlers, badgeHandler, oembedHandler, healthHandler, langHandlers, i18nService)
|
|
|
|
httpServer := &http.Server{
|
|
Addr: cfg.Server.ListenAddr,
|
|
Handler: handlers.RequestLogger(handlers.SecureHeaders(router)),
|
|
}
|
|
|
|
return &Server{
|
|
httpServer: httpServer,
|
|
db: db,
|
|
router: router,
|
|
templates: tmpl,
|
|
baseURL: cfg.App.BaseURL,
|
|
adminEmails: cfg.App.AdminEmails,
|
|
authService: authService,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) Start() error {
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
if err := s.httpServer.Shutdown(ctx); err != nil {
|
|
return err
|
|
}
|
|
if s.db != nil {
|
|
return s.db.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) GetAddr() string {
|
|
return s.httpServer.Addr
|
|
}
|
|
|
|
func (s *Server) Router() *chi.Mux {
|
|
return s.router
|
|
}
|
|
|
|
func (s *Server) RegisterRoutes(fn func(r *chi.Mux)) {
|
|
fn(s.router)
|
|
}
|
|
|
|
func (s *Server) GetTemplates() *template.Template {
|
|
return s.templates
|
|
}
|
|
|
|
func (s *Server) GetDB() *sql.DB {
|
|
return s.db
|
|
}
|
|
|
|
func (s *Server) GetAdminEmails() []string {
|
|
return s.adminEmails
|
|
}
|
|
|
|
func (s *Server) GetAuthService() *auth.OauthService {
|
|
return s.authService
|
|
}
|
|
|
|
func initInfrastructure(ctx context.Context, cfg *config.Config) (*sql.DB, *template.Template, *crypto.Ed25519Signer, *i18n.I18n, error) {
|
|
db, err := database.InitDB(ctx, database.Config{
|
|
DSN: cfg.Database.DSN,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("failed to initialize database: %w", err)
|
|
}
|
|
|
|
tmpl, err := initTemplates()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("failed to initialize templates: %w", err)
|
|
}
|
|
|
|
signer, err := crypto.NewEd25519Signer()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("failed to initialize signer: %w", err)
|
|
}
|
|
|
|
localesDir := getLocalesDir()
|
|
i18nService, err := i18n.NewI18n(localesDir)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("failed to initialize i18n: %w", err)
|
|
}
|
|
|
|
return db, tmpl, signer, i18nService, nil
|
|
}
|
|
|
|
func setupRouter(
|
|
authHandlers *handlers.AuthHandlers,
|
|
authMiddleware *handlers.AuthMiddleware,
|
|
signatureHandlers *handlers.SignatureHandlers,
|
|
badgeHandler *handlers.BadgeHandler,
|
|
oembedHandler *handlers.OEmbedHandler,
|
|
healthHandler *handlers.HealthHandler,
|
|
langHandlers *handlers.LangHandlers,
|
|
i18nService *i18n.I18n,
|
|
) *chi.Mux {
|
|
router := chi.NewRouter()
|
|
|
|
// Apply i18n middleware to all routes
|
|
router.Use(i18n.Middleware(i18nService))
|
|
|
|
// Serve static files (CSS)
|
|
staticDir := getStaticDir()
|
|
fileServer := http.FileServer(http.Dir(staticDir))
|
|
router.Get("/static/*", http.StripPrefix("/static/", fileServer).ServeHTTP)
|
|
|
|
router.Get("/", signatureHandlers.HandleIndex)
|
|
router.Get("/login", authHandlers.HandleLogin)
|
|
router.Get("/logout", authHandlers.HandleLogout)
|
|
router.Get("/oauth2/callback", authHandlers.HandleOAuthCallback)
|
|
router.Get("/status", signatureHandlers.HandleStatusJSON)
|
|
router.Get("/status.png", badgeHandler.HandleStatusPNG)
|
|
router.Get("/oembed", oembedHandler.HandleOEmbed)
|
|
router.Get("/embed", oembedHandler.HandleEmbedView)
|
|
router.Get("/health", healthHandler.HandleHealth)
|
|
// Alias to match documentation and install script
|
|
router.Get("/healthz", healthHandler.HandleHealth)
|
|
|
|
// Language switcher
|
|
router.Get("/lang/{code}", langHandlers.HandleLangSwitch)
|
|
|
|
router.Get("/sign", authMiddleware.RequireAuth(signatureHandlers.HandleSignGET))
|
|
router.Post("/sign", authMiddleware.RequireAuth(signatureHandlers.HandleSignPOST))
|
|
router.Get("/signatures", authMiddleware.RequireAuth(signatureHandlers.HandleUserSignatures))
|
|
|
|
return router
|
|
}
|
|
|
|
func initTemplates() (*template.Template, error) {
|
|
templatesDir := getTemplatesDir()
|
|
|
|
baseTemplatePath := filepath.Join(templatesDir, "base.html.tpl")
|
|
tmpl, err := template.New("base").ParseFiles(baseTemplatePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse base template: %w", err)
|
|
}
|
|
|
|
additionalTemplates := []string{"index.html.tpl", "sign.html.tpl", "signatures.html.tpl", "embed.html.tpl", "admin_dashboard.html.tpl", "admin_doc_details.html.tpl", "error.html.tpl"}
|
|
for _, templateFile := range additionalTemplates {
|
|
templatePath := filepath.Join(templatesDir, templateFile)
|
|
_, err = tmpl.ParseFiles(templatePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse template %s: %w", templateFile, err)
|
|
}
|
|
}
|
|
|
|
return tmpl, nil
|
|
}
|
|
|
|
func getTemplatesDir() string {
|
|
if envDir := os.Getenv("ACKIFY_TEMPLATES_DIR"); envDir != "" {
|
|
return envDir
|
|
}
|
|
|
|
if execPath, err := os.Executable(); err == nil {
|
|
execDir := filepath.Dir(execPath)
|
|
defaultDir := filepath.Join(execDir, "templates")
|
|
if _, err := os.Stat(defaultDir); err == nil {
|
|
return defaultDir
|
|
}
|
|
}
|
|
|
|
possiblePaths := []string{
|
|
"templates", // When running from project root
|
|
"./templates", // Alternative relative path
|
|
}
|
|
|
|
for _, path := range possiblePaths {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path
|
|
}
|
|
}
|
|
|
|
return "templates"
|
|
}
|
|
|
|
func getLocalesDir() string {
|
|
if envDir := os.Getenv("ACKIFY_LOCALES_DIR"); envDir != "" {
|
|
return envDir
|
|
}
|
|
|
|
if execPath, err := os.Executable(); err == nil {
|
|
execDir := filepath.Dir(execPath)
|
|
defaultDir := filepath.Join(execDir, "locales")
|
|
if _, err := os.Stat(defaultDir); err == nil {
|
|
return defaultDir
|
|
}
|
|
}
|
|
|
|
possiblePaths := []string{
|
|
"locales", // When running from project root
|
|
"./locales", // Alternative relative path
|
|
}
|
|
|
|
for _, path := range possiblePaths {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path
|
|
}
|
|
}
|
|
|
|
return "locales"
|
|
}
|
|
|
|
func getStaticDir() string {
|
|
if envDir := os.Getenv("ACKIFY_STATIC_DIR"); envDir != "" {
|
|
return envDir
|
|
}
|
|
|
|
if execPath, err := os.Executable(); err == nil {
|
|
execDir := filepath.Dir(execPath)
|
|
defaultDir := filepath.Join(execDir, "static")
|
|
if _, err := os.Stat(defaultDir); err == nil {
|
|
return defaultDir
|
|
}
|
|
}
|
|
|
|
possiblePaths := []string{
|
|
"static", // When running from project root
|
|
"./static", // Alternative relative path
|
|
}
|
|
|
|
for _, path := range possiblePaths {
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path
|
|
}
|
|
}
|
|
|
|
return "static"
|
|
}
|