| 1 | // Package sheets is the Sheets instance app: one process, one SQLite file, |
| 2 | // one team. |
| 3 | package sheets |
| 4 | |
| 5 | import ( |
| 6 | nativeauth "amadan.net/rastrillo/native/server" |
| 7 | "net/http" |
| 8 | |
| 9 | "amadan.net/oficina/estilo.git/chrome" |
| 10 | "amadan.net/oficina/home/connect" |
| 11 | "amadan.net/rastrillo/rastrillo/sessions" |
| 12 | |
| 13 | "amadan.net/rastrillo/rastrillo/auth" |
| 14 | "amadan.net/rastrillo/rastrillo/csrf" |
| 15 | "amadan.net/rastrillo/rastrillo/jobs" |
| 16 | "github.com/go-chi/chi/v5" |
| 17 | "gorm.io/gorm" |
| 18 | |
| 19 | "amadan.net/oficina/sheets/internal/sheets/live" |
| 20 | "amadan.net/oficina/sheets/internal/sheets/seal" |
| 21 | ) |
| 22 | |
| 23 | // Config is everything an App needs. |
| 24 | // |
| 25 | // A struct rather than positional parameters: this grew from two to |
| 26 | // five arguments in one plan, and the fifth — Origin — is a string |
| 27 | // sitting next to another string, which is the shape a call site gets |
| 28 | // silently wrong. |
| 29 | type Config struct { |
| 30 | // DB is the app's GORM handle. Nil only in tests that render pages |
| 31 | // without touching storage. |
| 32 | DB *gorm.DB |
| 33 | |
| 34 | // Auth is the sign-in flow. Nil ONLY in tests: Router then serves |
| 35 | // the sign-in page but mounts none of auth's handlers, so a |
| 36 | // nil-auth App can render pages without a database or a mailer. |
| 37 | // Production always passes one — main refuses to boot without the |
| 38 | // configuration auth needs. |
| 39 | Auth *auth.Auth |
| 40 | |
| 41 | // Sealer seals every cell, title and tab name. Nil only in tests |
| 42 | // that never reach a sheet. |
| 43 | Sealer *seal.Sealer |
| 44 | |
| 45 | // Origin is this instance's external origin, scheme included. It is |
| 46 | // the CSRF same-origin check's reference value. |
| 47 | Origin string |
| 48 | |
| 49 | // Admit is the allowlist, the static half of App.Admits. Admits is |
| 50 | // consulted before a link is SENT (admissionMailer) and at |
| 51 | // redemption (auth.Config.Authorize). See handleBegin. |
| 52 | Admit func(string) bool |
| 53 | } |
| 54 | |
| 55 | // App holds the wiring. One per process. |
| 56 | type App struct { |
| 57 | native *nativeauth.Auth |
| 58 | home *connect.Bridge |
| 59 | homeRoutes *connect.Routes |
| 60 | homeSessions *sessions.Sessions |
| 61 | db *gorm.DB |
| 62 | auth *auth.Auth |
| 63 | sealer *seal.Sealer |
| 64 | origin string |
| 65 | admit func(string) bool |
| 66 | // directory is the hub directory (HOME_DIRECTORY_ORIGIN), set only |
| 67 | // with Home configured: the app switcher's link. Empty, no switcher. |
| 68 | directory string |
| 69 | |
| 70 | // hub fans every committed edit out to every other open tab. One per |
| 71 | // process, because it holds the live subscriptions: a hub per |
| 72 | // request would fan out to nobody, silently. |
| 73 | hub *live.Hub |
| 74 | |
| 75 | // jobs runs XLSX import off the request (spec §11). One registry |
| 76 | // per process for the same reason as the hub: it holds the running |
| 77 | // goroutines, and a registry per request could never answer for a |
| 78 | // job started by an earlier one. |
| 79 | // |
| 80 | // In-memory and unpersisted, which is the package's own design and |
| 81 | // the right one here: a deploy ends the goroutine, so a persisted |
| 82 | // row would only persist a lie about work that is no longer |
| 83 | // running. |
| 84 | jobs *jobs.Jobs |
| 85 | |
| 86 | // jobPages is the status page, its polled fragment and its event |
| 87 | // stream, over that registry. |
| 88 | jobPages *jobs.Handlers |
| 89 | |
| 90 | // previews holds the sheet list's rendered thumbnails. One per |
| 91 | // process, and in memory only — see previewcache.go for why a |
| 92 | // column would be the wrong place even though the content is safe. |
| 93 | previews *previewCache |
| 94 | } |
| 95 | |
| 96 | // New builds an App. |
| 97 | func New(cfg Config) *App { |
| 98 | a := &App{ |
| 99 | db: cfg.DB, |
| 100 | auth: cfg.Auth, |
| 101 | sealer: cfg.Sealer, |
| 102 | origin: cfg.Origin, |
| 103 | admit: cfg.Admit, |
| 104 | hub: live.NewHub(), |
| 105 | // nil logger means slog.Default(), which is exactly what main |
| 106 | // passes rastrillo everywhere else. |
| 107 | jobs: jobs.New(nil), |
| 108 | previews: newPreviewCache(), |
| 109 | } |
| 110 | // NewHandlers' only failure is a nil field in a Config this |
| 111 | // function just built, so a failure here is this line being wrong |
| 112 | // and nothing else — the same class of mistake as a template that |
| 113 | // will not parse, and handled the same way (parsePages). Failing at |
| 114 | // construction is louder than failing on the first import. |
| 115 | h, err := jobs.NewHandlers(jobs.Config{ |
| 116 | Jobs: a.jobs, |
| 117 | Render: a.renderJobPage, |
| 118 | RenderFragment: a.renderJobFragment, |
| 119 | }) |
| 120 | if err != nil { |
| 121 | panic("sheets: wiring job handlers: " + err.Error()) |
| 122 | } |
| 123 | a.jobPages = h |
| 124 | // Reclaim imports abandoned by a process that is not coming back. |
| 125 | // Only ones old enough to have no live job under any scheduling — |
| 126 | // two instances CAN overlap (a draining rolling replacement, a |
| 127 | // duplicate start), and a sweep that assumed otherwise would delete |
| 128 | // a running import out from under the person doing it. See |
| 129 | // sweepStaleImports. |
| 130 | a.sweepStaleImports() |
| 131 | return a |
| 132 | } |
| 133 | |
| 134 | // UseAuth attaches the sign-in flow after construction. |
| 135 | // |
| 136 | // It exists because the two are mutually dependent: auth needs an |
| 137 | // admission gate that reads this app's grants table, and the app needs |
| 138 | // auth to read sessions. Building the App first and attaching auth |
| 139 | // second is the smaller of the two knots — the alternative is a gate |
| 140 | // that closes over a database handle nobody else uses. |
| 141 | func (a *App) UseAuth(x *auth.Auth) { a.auth = x } |
| 142 | |
| 143 | // Router returns the mounted handler. |
| 144 | func (a *App) Router() http.Handler { |
| 145 | r := chi.NewRouter() |
| 146 | if a.native != nil { |
| 147 | r.Use(a.nativeResume) |
| 148 | } |
| 149 | |
| 150 | // Origin-checking CSRF, app-wide, on every state-changing method. |
| 151 | // App-wide rather than per-route because the failure mode of |
| 152 | // forgetting one route is silent, and there is no token to mint or |
| 153 | // remember to check. |
| 154 | // |
| 155 | // Skipped entirely when Origin is empty, which only ever happens in |
| 156 | // a test App: a Protect("") would compare every Origin header |
| 157 | // against the empty string and refuse every POST, turning a missing |
| 158 | // test fixture into a wall of 403s that look like an auth bug. |
| 159 | if a.homeRoutes != nil { |
| 160 | r.Use(a.homeRoutes.Middleware) |
| 161 | } else if a.origin != "" { |
| 162 | r.Use(csrf.Protect(a.origin)) |
| 163 | } |
| 164 | |
| 165 | // This /healthz is NOT what answers in a deployed instance: |
| 166 | // rastrillo.Serve registers its own GET /healthz on the outer mux |
| 167 | // (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that |
| 168 | // exact pattern over this router's "/" mount, so the outer one wins |
| 169 | // every time in production. It exists here so App's own router is |
| 170 | // testable in isolation (see TestHealthz) and as the seam later |
| 171 | // routes mount alongside. |
| 172 | r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { |
| 173 | w.WriteHeader(http.StatusOK) |
| 174 | }) |
| 175 | // Exact "/" only, not a catch-all: every other unknown path is the |
| 176 | // 404 page for a browser and plain text for anything else |
| 177 | // (TestIndexIsNotACatchAll pins both halves). |
| 178 | r.NotFound(a.routeNotFound) |
| 179 | r.MethodNotAllowed(a.methodNotAllowed(r)) |
| 180 | r.Get("/", a.handleIndex) |
| 181 | a.mountAuth(r) |
| 182 | if a.native != nil { |
| 183 | r.Handle("/native/*", a.native) |
| 184 | } |
| 185 | // Fingerprinted and immutably cached, mounted at the FS's own path |
| 186 | // with no prefix stripped — rastrillo's Assets uses |
| 187 | // http.FileServerFS semantics, where the URL path is "/" plus the |
| 188 | // FS path. |
| 189 | r.Handle("/static/*", staticHandler()) |
| 190 | // estilo serves the suite's app icons (the bar's brand). |
| 191 | mountEstilo(r) |
| 192 | // Light/dark is a per-person preference set by a form, so it works |
| 193 | // with scripting off. estilo's handler and cookie; inside the CSRF |
| 194 | // check above and outside the session group, so an expired session |
| 195 | // cannot turn the click into a sign-in loop (spec §8.3). |
| 196 | r.Post("/appearance", chrome.HandleAppearance) |
| 197 | |
| 198 | // A share link is a capability, so this route is deliberately |
| 199 | // OUTSIDE the session group: no account, no member, no directory |
| 200 | // entry (spec §8.3). The token is the entire authorization. |
| 201 | r.Get("/l/{token}", a.handleLinkView) |
| 202 | |
| 203 | r.Group(func(r chi.Router) { |
| 204 | r.Use(a.requireSession) |
| 205 | // After the guard, so a 500 shows the viewer's own chrome. |
| 206 | r.Use(a.recoverToErrorPage) |
| 207 | r.Get("/api/native/jobs/{id}", a.handleNativeJob) |
| 208 | r.Get("/sheets", a.handleSheetList) |
| 209 | r.Post("/sheets", a.handleSheetCreate) |
| 210 | // Static before the {sheetID} pattern below. chi prefers an |
| 211 | // exact segment over a wildcard whatever the order, so this is |
| 212 | // belt and braces rather than load-bearing — but a route that |
| 213 | // only works because of resolution order is a route one |
| 214 | // refactor away from parsing "import" as a sheet id. |
| 215 | // TestImportPathIsNotASheetID pins it. |
| 216 | r.Get("/sheets/import", a.handleImportPage) |
| 217 | // Import. A whole workbook is too much work for a request, so |
| 218 | // it 303s to a job (spec §11) rather than holding the |
| 219 | // connection open — and the drop zone on the list posts to |
| 220 | // exactly this route, because drag and drop is an enhancement |
| 221 | // over the form, not a second way in. |
| 222 | r.Post("/sheets/import", a.handleSheetImport) |
| 223 | r.Get("/sheets/{sheetID}", a.handleSheetPage) |
| 224 | // Rename. The other half of a Create button that no longer |
| 225 | // asks for a name — see handlers_rename.go. |
| 226 | r.Post("/sheets/{sheetID}/rename", a.handleSheetRename) |
| 227 | // The scriptless view. Spec §6 names the grid editor, drag-fill |
| 228 | // and live sync as the only script-required things; everything |
| 229 | // else, including editing any cell, works here. |
| 230 | r.Get("/sheets/{sheetID}/export.xlsx", a.handleExport) |
| 231 | r.Get("/sheets/{sheetID}/table", a.handleTable) |
| 232 | r.Post("/sheets/{sheetID}/table", a.handleTableWrite) |
| 233 | // Sharing. Sheet owners only — see sheetForSharing. |
| 234 | r.Get("/sheets/{sheetID}/share", a.handleShare) |
| 235 | r.Post("/sheets/{sheetID}/share", a.handleShareGrant) |
| 236 | r.Post("/sheets/{sheetID}/share/revoke", a.handleShareRevoke) |
| 237 | r.Post("/sheets/{sheetID}/share/link", a.handleLinkCreate) |
| 238 | r.Post("/sheets/{sheetID}/share/link/revoke", a.handleLinkRevoke) |
| 239 | // History, restore and undo. All three are scriptless, because |
| 240 | // putting something back is exactly the moment somebody wants |
| 241 | // the plainest possible interface. |
| 242 | r.Get("/sheets/{sheetID}/history", a.handleHistory) |
| 243 | r.Post("/sheets/{sheetID}/restore", a.handleRestore) |
| 244 | r.Post("/sheets/{sheetID}/undo", a.handleUndo) |
| 245 | // Comments. Reading needs a viewer's grant; writing needs |
| 246 | // commenter, which is what that role is for. |
| 247 | r.Get("/sheets/{sheetID}/comments", a.handleComments) |
| 248 | r.Post("/sheets/{sheetID}/comments", a.handleCommentAdd) |
| 249 | r.Post("/sheets/{sheetID}/comments/resolve", a.handleCommentResolve) |
| 250 | // Sort and filter. Non-destructive: it reorders what is shown, |
| 251 | // never what is stored — see ViewSpec for why that is a |
| 252 | // constraint and not a preference. |
| 253 | r.Post("/sheets/{sheetID}/view", a.handleViewSave) |
| 254 | |
| 255 | // The grid's own API. Under /api because it answers JSON and |
| 256 | // carries no chrome; behind the same session as everything |
| 257 | // else, because a sheet is a sheet whichever content type you |
| 258 | // ask for it in. |
| 259 | r.Get("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellsGet) |
| 260 | r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellPost) |
| 261 | r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells/batch", a.handleCellBatchPost) |
| 262 | r.Post("/api/sheets/{sheetID}/tabs/{tabID}/format", a.handleFormatPost) |
| 263 | r.Post("/api/sheets/{sheetID}/tabs/{tabID}/size", a.handleSizePost) |
| 264 | r.Get("/api/sheets/{sheetID}/tabs/{tabID}/stream", a.handleStream) |
| 265 | |
| 266 | // The job status page, its polled fragment and its event |
| 267 | // stream. Inside the session group because jobs.Handlers reads |
| 268 | // the viewer with sessions.Current and answers a job only to |
| 269 | // its owner — mounted outside, every one of them would 404. |
| 270 | // |
| 271 | // The parameter MUST be named "id": rastrillo reads it with |
| 272 | // r.PathValue("id"), which chi populates from its own route |
| 273 | // pattern. A rename here is a silent 404 on every job page, |
| 274 | // which is why TestJobRouteParamIsNamedID pins it. |
| 275 | r.Get("/jobs/{id}", a.jobPages.StatusPage) |
| 276 | r.Get("/jobs/{id}/fragment", a.jobPages.Fragment) |
| 277 | r.Get("/jobs/{id}/events", a.jobPages.Events) |
| 278 | }) |
| 279 | return r |
| 280 | } |
| 281 | |
| 282 | // requireSession is auth.RequireSession in production and a |
| 283 | // pass-through when there is no auth to require one with. |
| 284 | // |
| 285 | // The pass-through is for tests only, and it is deliberate rather than |
| 286 | // accidental: a nil-auth App has no sessions at all, so requiring one |
| 287 | // would make every route below untestable without a mailer, an SMTP |
| 288 | // refusal and a full magic-link round trip. Production cannot reach it |
| 289 | // — main exits before New if sign-in is not configured. |
| 290 | func (a *App) requireSession(next http.Handler) http.Handler { |
| 291 | if a.home != nil { |
| 292 | return a.homeRequire(next) |
| 293 | } |
| 294 | if a.auth == nil { |
| 295 | return next |
| 296 | } |
| 297 | return a.auth.RequireSession(a.rejectDisabledHome(next)) |
| 298 | } |
| 299 | |