app.go

  1package rtw
  2
  3import (
  4	"fmt"
  5	"log/slog"
  6	"net/http"
  7	"path/filepath"
  8	"time"
  9
 10	"git.kilimanjaro.io/rtw/api"
 11	"git.kilimanjaro.io/rtw/document"
 12	"git.kilimanjaro.io/rtw/httperr"
 13	"git.kilimanjaro.io/rtw/pages"
 14	"git.kilimanjaro.io/rtw/pkg/middleware"
 15	"git.kilimanjaro.io/rtw/routing"
 16	"git.kilimanjaro.io/rtw/user"
 17	"git.kilimanjaro.io/ygo/ysweet"
 18	"github.com/a-h/templ"
 19)
 20
 21// App holds the fully configured router and internal services.
 22// Use NewApp to create it, and call Shutdown for graceful cleanup.
 23type App struct {
 24	Router      *middleware.Router
 25	docManager  *DocManager
 26	userService *user.Service
 27	docService  *document.Service
 28}
 29
 30// Shutdown performs graceful cleanup of all internal services.
 31func (a *App) Shutdown() {
 32	if a.docManager != nil {
 33		slog.Info("shutting down DocManager...")
 34		a.docManager.Shutdown()
 35	}
 36	if a.userService != nil {
 37		slog.Info("shutting down UserService...")
 38		a.userService.Shutdown()
 39	}
 40	if a.docService != nil {
 41		slog.Info("shutting down DocumentService")
 42		a.docService.Shutdown()
 43	}
 44}
 45
 46// NewApp creates a fully configured App with all routes registered.
 47func NewApp(cfg *Config) (*App, error) {
 48	// Create services
 49	userService, err := user.NewService(user.WithBasePath(cfg.Server.DataDir))
 50	if err != nil {
 51		return nil, fmt.Errorf("app config error: user service: %w", err)
 52	}
 53	errorHandler := httperr.NewHandler(httperr.WithOnError("/500"), httperr.WithOnUnauthorized("/403"), httperr.WithOnNotFound("/404"))
 54	docService, err := document.NewService(document.WithBasePath(cfg.Server.DataDir))
 55	if err != nil {
 56		return nil, fmt.Errorf("app config error: document service: %w", err)
 57	}
 58	docManager := NewDocManager(cfg.YSweet.URL, docService)
 59
 60	// Create router with the default middleware stack
 61	router := middleware.NewDefaultRouter()
 62
 63	// Health check endpoint
 64	start := time.Now()
 65	build := getBuildInfo()
 66	router.HandleFunc("GET /health", healthHandler(start, build, cfg))
 67
 68	// y-sweet websocket proxy
 69	ysweetOpts := []ysweet.ProxyOption{
 70		ysweet.WithAllowedOrigins(cfg.Server.PublicURL, "localhost:*", "127.0.0.1:*"),
 71	}
 72
 73	// Routes that may differ based on the presence of a session
 74	router.Group(func(r *middleware.Router) {
 75		r.Use(middleware.RequestLogger, middleware.RequestID, Session(userService, errorHandler, CreateAnonymousSessions(false)))
 76		// user homepage
 77		r.Handle("GET /u/{id}", pages.NewUserHomeHandler(userService, docService, errorHandler, cfg.Protomaps.APIKey))
 78	})
 79
 80	// Routes related to the trip editor that require anon or logged-in session
 81	router.Group(func(r *middleware.Router) {
 82		r.Use(middleware.RequestLogger, middleware.RequestID, Session(userService, errorHandler, CreateAnonymousSessions(true)))
 83		// handles calls from the y-sweet client to get a websocket auth token
 84		r.Handle("GET /api/ws/auth/{docID}", api.NewWebsocketAuthHandler(cfg.Server.PublicURL, userService, docService))
 85		// waldo geocoding proxy
 86		r.Handle("GET /forward", WaldoProxyHandler(cfg.Geocoder.URL))
 87		r.Handle("GET /reverse", WaldoProxyHandler(cfg.Geocoder.URL))
 88		r.Handle("GET /edit/{docID}", pages.NewEditHandler(userService, docService, errorHandler))
 89		r.Handle("GET /edit", pages.NewEditHandler(userService, docService, errorHandler))
 90	})
 91
 92	// Websocket connection endpoint for the proxy to the upstream Y-sweet server
 93	// No session middleware, client does not send credentialed requests. Use the token query parameter
 94	// to parse the PASETO token claim `user_id` if trusted source of UserID is required.
 95	router.Handle("GET /ws/{docID}", YSweetProxyHandler(cfg.YSweet.URL, docManager, userService, ysweetOpts...))
 96
 97	// Route file handler for serving route data
 98	// Compression is applied globally by NewDefaultRouter; no manual wrapper needed.
 99	// Immutable caching is handled in the RouteFileHandler
100	router.Handle("GET /route/{hash}", routing.RouteFileHandler(filepath.Join(cfg.Server.DataDir, "data", "route")))
101
102	// User public pages — wrapped with Session middleware so that login/signup
103	// handlers can detect existing anonymous sessions for permission migration.
104	// NoExpiredRedirect prevents redirect loops when visiting /login with a stale
105	// session cookie; the cookie is cleared and the user can log in normally.
106	router.Group(func(r *middleware.Router) {
107		r.Use(middleware.RequestLogger, middleware.RequestID, Session(userService, errorHandler, CreateAnonymousSessions(false), NoExpiredRedirect()))
108		r.Handle("/login", pages.NewLoginSignupHandler(userService, errorHandler, false))
109		r.Handle("/signup", pages.NewLoginSignupHandler(userService, errorHandler, true))
110		r.Handle("GET /about", templ.Handler(pages.About()))
111		// Error pages
112		r.Handle("GET /404", templ.Handler(pages.NotFound()))
113		r.Handle("GET /500", templ.Handler(pages.ServerError()))
114		r.Handle("GET /403", templ.Handler(pages.Forbidden()))
115
116		r.Handle("GET /{$}", http.RedirectHandler("/about", http.StatusSeeOther))
117	})
118
119	// Static assets from the embedded filesystem.
120	router.Group(func(r *middleware.Router) {
121		r.Use(middleware.Cache(middleware.MaxCacheAge, middleware.WithImmutable()))
122		r.Handle("GET /static/{path...}", http.FileServerFS(pages.StaticFS))
123	})
124
125	return &App{
126		Router:      router,
127		docManager:  docManager,
128		userService: userService,
129		docService:  docService,
130	}, nil
131}