oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "errors"
5 "net/http"
6 "strconv"
7
8 "github.com/go-chi/chi/v5"
9
10 "amadan.net/oficina/sheets/internal/sheets/store"
11)
12
13// linkView is the anonymous read-only page.
14type linkView struct {
15 Sheet SheetSummary
16 Rows []tableRow
17 Cols []string
18 Page int
19 Pages int
20
21 HasPrev bool
22 HasNext bool
23 PrevPage int
24 NextPage int
25 Token string
26
27 // CanComment is true for a commenter link. The link itself still
28 // buys only reading — spec §8.3: the moment somebody wants to
29 // write, they sign in, which is what mints a guest member and
30 // produces a directory entry. Comments are a later slice; this
31 // field is what that slice will read, and until then it decides
32 // nothing but a sentence on the page.
33 CanComment bool
34}
35
36// handleLinkView serves a sheet to whoever holds a link token.
37//
38// No session, no member, no directory entry — spec §8.3: a view link
39// mints nothing. It is a capability, and the only thing it can do is
40// read.
41//
42// It renders the scriptless table rather than the grid, and that is the
43// honest shape rather than a shortcut. The grid's data comes from
44// session-gated endpoints, and its live stream would put a link holder
45// in the fan-out — while §8.3 rules that a link viewer gets no presence
46// cursor and no name in the presence stack, because presence is a write
47// to shared awareness state.
48func (a *App) handleLinkView(w http.ResponseWriter, r *http.Request) {
49 token := chi.URLParam(r, "token")
50 link, ok := a.linkFor(token)
51 if !ok {
52 // One answer for a token that never existed, one that was
53 // revoked, and one whose sheet is gone. Distinguishing them
54 // would turn this route into an oracle for which tokens were
55 // ever real.
56 a.notFound(w, r)
57 return
58 }
59
60 s, err := a.summarizeByID(link.SheetID)
61 if err != nil {
62 a.notFound(w, r)
63 return
64 }
65
66 h, err := a.openTabFor(s)
67 if err != nil {
68 a.fail(w, r, err)
69 return
70 }
71 defer h.Release()
72
73 page := intParam(r, "page")
74 if page < 1 {
75 page = 1
76 }
77 pages := (maxWindowRows + tableRowsPerPage - 1) / tableRowsPerPage
78 if page > pages {
79 page = pages
80 }
81
82 // editR/editC of -1: no cell is ever editable here. The template
83 // shares tableRow with the signed-in view, and passing a coordinate
84 // that can never match is how "read-only" is expressed in the data
85 // rather than trusted to the template.
86 view := linkView{
87 Sheet: s, Page: page, Pages: pages,
88 HasPrev: page > 1, HasNext: page < pages,
89 PrevPage: page - 1, NextPage: page + 1,
90 Token: token,
91 CanComment: SheetRole(link.Role) == SheetCommenter,
92 }
93 for c := 0; c < tableCols; c++ {
94 view.Cols = append(view.Cols, colLabel(c))
95 }
96 view.Rows = a.tableRows(h, (page-1)*tableRowsPerPage, -1, -1)
97
98 f := onSheet(s, "")
99 f.public = true
100 a.render(w, r, "link", f, http.StatusOK, view)
101}
102
103// summarizeByID reads a sheet without a grant check.
104//
105// It exists for exactly one caller — the link route, where the token IS
106// the authorization — and is deliberately not named sheetSummary, so
107// nothing reaches for it by accident from a route that has a viewer and
108// should be asking about their grant.
109func (a *App) summarizeByID(id uint64) (SheetSummary, error) {
110 if a.db == nil {
111 return SheetSummary{}, errNoDB
112 }
113 if a.sealer == nil {
114 return SheetSummary{}, errNoSealer
115 }
116 var sheet store.Sheet
117 if err := a.db.Where("id = ? AND deleted_at IS NULL AND filling_since IS NULL", id).First(&sheet).Error; err != nil {
118 return SheetSummary{}, err
119 }
120 return a.summarize(sheet)
121}
122
123// linkURL is the address to hand somebody. Built here rather than in a
124// template so the origin comes from configuration and not from whatever
125// Host header the request arrived with.
126func (a *App) linkURL(token string) string {
127 return a.origin + "/l/" + token
128}
129
130// handleLinkCreate mints a link and shows the token once.
131//
132// This is the one POST on the share screen that answers with a page
133// instead of a 303, and the exception is deliberate. The token exists
134// in readable form for exactly this moment — only its SHA-256 is
135// stored — so it has to reach the person in this response or not at
136// all. Every way of carrying it through a redirect puts it somewhere we
137// do not control: a query string is written into the platform edge's
138// access logs, which AGENTS.md names as storage we cannot grep, and a
139// server-side stash would have to survive a redirect landing on another
140// instance, failing by showing nothing where the token is already gone.
141// Rendering here keeps it in this process, on this goroutine, in one
142// response body.
143//
144// The cost is that a reload re-submits the form and mints a second
145// link. That is visible in the list below and revocable from it, which
146// is the trade the other options do not offer: a token in an access log
147// cannot be taken back, and a token lost between instances cannot be
148// recovered.
149func (a *App) handleLinkCreate(w http.ResponseWriter, r *http.Request) {
150 s, viewer, ok := a.sheetForSharing(w, r)
151 if !ok {
152 return
153 }
154 role := SheetRole(r.FormValue("role"))
155 token, err := a.createLink(s.ID, role, viewer)
156 if errors.Is(err, errBadLinkRole) {
157 // The only failure the person can act on, and the only one they
158 // are told about in these terms.
159 a.shareRedirect(w, r, s, "bad-link-role")
160 return
161 }
162 if err != nil {
163 // Ours, not theirs: the same 500 every other broken write on
164 // this screen gives, rather than a sentence blaming a role
165 // choice that was fine.
166 a.fail(w, r, err)
167 return
168 }
169 a.renderShare(w, r, s, viewer, "link", token)
170}
171
172// handleLinkRevoke turns a link off.
173func (a *App) handleLinkRevoke(w http.ResponseWriter, r *http.Request) {
174 s, _, ok := a.sheetForSharing(w, r)
175 if !ok {
176 return
177 }
178 linkID, err := strconv.ParseUint(r.FormValue("link"), 10, 64)
179 if err != nil || linkID == 0 {
180 a.notFound(w, r)
181 return
182 }
183 if err := a.revokeLink(s.ID, linkID); err != nil {
184 a.fail(w, r, err)
185 return
186 }
187 a.shareRedirect(w, r, s, "link-revoked")
188}
189