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}