oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1// Package sheets is the Sheets instance app: one process, one SQLite file,
2// one team.
3package sheets
4
5import (
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.
29type 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.
56type 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.
97func 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.
141func (a *App) UseAuth(x *auth.Auth) { a.auth = x }
142
143// Router returns the mounted handler.
144func (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.
290func (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