oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "errors"
5 "net/http"
6
7 "amadan.net/rastrillo/rastrillo"
8)
9
10// sharePage is the share screen's view data, resolved in the handler rather
11// than in the template, as every other page here does it.
12type sharePage struct {
13 Frame Frame
14 Title string
15 DocTitle string
16 Sqid string
17 Heading string
18 AddressLabel string
19 RoleLabel string
20 RoleReader string
21 RoleWriter string
22 ShareButton string
23 RevokeButton string
24 BackLabel string
25 EmptyBody string
26 Error string
27 Grants []shareRow
28 SignOutLabel string
29
30 // The public link, the third rung of ruling 15. PublicURL is empty
31 // when the document is not published, and that emptiness is the
32 // switch's state — one field rather than a bool beside a string that
33 // could disagree with it.
34 PublicHeading string
35 PublicBody string
36
37 // Published is whether a link exists; PublicURL is the link itself and
38 // is set only in the render immediately after publishing.
39 //
40 // Two fields rather than one because they are two different facts now:
41 // under ruling 16 the app knows a document is published without being
42 // able to say what its URL is. Collapsing them would make "published"
43 // unrepresentable whenever the URL is unavailable — which is almost
44 // always.
45 Published bool
46 PublicURL string
47 PublishButton string
48 UnpublishBtn string
49 ReplaceButton string
50 CopyHint string
51 OnceWarning string
52 PublishedBody string
53
54 // The role picker's option cards (rastrillo's choice-field), the
55 // one-time link's accessible name, and the Copy button with the
56 // status it announces (fixtures/share-link.mjs).
57 RoleChoices []roleChoice
58 URLLabel string
59 CopyButton string
60 CopiedLabel string
61 ShareScript string
62}
63
64// roleChoice is one option card: rastrillo's choice-field reads Value,
65// Title, Desc and Checked.
66type roleChoice struct {
67 Value, Title, Desc string
68 Checked bool
69}
70
71// shareRow is one person who reaches this document.
72type shareRow struct {
73 Address string
74 Role string
75}
76
77// shareGet serves GET /d/{sqid}/share: who reaches this document, and the
78// form that adds somebody.
79//
80// Owner-only, through Store.ListGrants, which goes through the owner-scoped
81// Get. A grantee — even a writer — gets the 404 a stranger gets: re-sharing
82// is not something a grant carries, and a document you may not share does not
83// announce itself as one you may not share (spec §4.2).
84func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) {
85 owner, err := h.owner(r)
86 if err != nil {
87 h.logger.Error("docs: resolve owner for share", "err", err)
88 h.fail(w, r, h.internalFailureStatus(), failStatus)
89 return
90 }
91 sqid := r.PathValue("sqid")
92
93 doc, err := h.store.Get(r.Context(), owner, sqid)
94 if err != nil {
95 h.ownerLookupFailure(w, r, err)
96 return
97 }
98 grants, err := h.store.ListGrants(r.Context(), owner, sqid)
99 if err != nil {
100 h.logger.Error("docs: list grants", "sqid", sqid, "err", err)
101 h.fail(w, r, h.internalFailureStatus(), failStatus)
102 return
103 }
104
105 h.renderShare(w, r, doc, grants, "", http.StatusOK)
106}
107
108func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Document, grants []*Grant, errKey string, status int) {
109 // A failure to read the published state is logged and then rendered as
110 // "not published" rather than failing the page: the grant list above it
111 // is the part somebody came here for, and a 500 over the switch would
112 // take the working half of the screen down with it.
113 published, err := h.store.IsPublished(r.Context(), doc.OwnerID, doc.Sqid)
114 if err != nil {
115 h.logger.Error("docs: read published state", "sqid", doc.Sqid, "err", err)
116 if wantsNative(r) {
117 h.fail(w, r, http.StatusServiceUnavailable, failStatus)
118 return
119 }
120 if h.store.team != nil {
121 h.ownerLookupFailure(w, r, err)
122 return
123 }
124 }
125
126 // The URL appears only in the render that follows the publish that
127 // minted it (ruling 16: the database holds a hash and cannot produce a
128 // URL). Collected here, once; a reload shows the published state
129 // without it, exactly as a visit tomorrow would.
130 publicURL := h.oneShots.take(oneShotKey(doc.OwnerID, doc.Sqid))
131
132 if wantsNative(r) {
133 type grant struct {
134 Address string `json:"address"`
135 Role Role `json:"role"`
136 }
137 rows := make([]grant, 0, len(grants))
138 for _, g := range grants {
139 rows = append(rows, grant{g.Subject, g.Role})
140 }
141 nativeJSON(w, r, status, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "grants": rows, "published": published, "public_url": publicURL, "error": errKey})
142 return
143 }
144 rows := make([]shareRow, 0, len(grants))
145 for _, g := range grants {
146 label := "docs.share.role.reader"
147 if g.Role == RoleWriter {
148 label = "docs.share.role.writer"
149 }
150 rows = append(rows, shareRow{Address: g.Subject, Role: rastrillo.T(r, label)})
151 }
152
153 page := sharePage{
154 Title: rastrillo.T(r, "docs.share.title"),
155 DocTitle: doc.Title,
156 Sqid: doc.Sqid,
157 Heading: rastrillo.T(r, "docs.share.heading"),
158 AddressLabel: rastrillo.T(r, "docs.share.address"),
159 RoleLabel: rastrillo.T(r, "docs.share.role"),
160 RoleReader: rastrillo.T(r, "docs.share.role.reader"),
161 RoleWriter: rastrillo.T(r, "docs.share.role.writer"),
162 ShareButton: rastrillo.T(r, "docs.share.submit"),
163 RevokeButton: rastrillo.T(r, "docs.share.revoke"),
164 BackLabel: rastrillo.T(r, "docs.share.back"),
165 EmptyBody: rastrillo.T(r, "docs.share.empty"),
166 Grants: rows,
167 SignOutLabel: rastrillo.T(r, "docs.signout.label"),
168
169 PublicHeading: rastrillo.T(r, "docs.share.public.heading"),
170 PublicBody: rastrillo.T(r, "docs.share.public.body"),
171 Published: published,
172 PublicURL: publicURL,
173 PublishButton: rastrillo.T(r, "docs.share.public.on"),
174 UnpublishBtn: rastrillo.T(r, "docs.share.public.off"),
175 ReplaceButton: rastrillo.T(r, "docs.share.public.replace"),
176 CopyHint: rastrillo.T(r, "docs.share.public.hint"),
177 OnceWarning: rastrillo.T(r, "docs.share.public.once"),
178 PublishedBody: rastrillo.T(r, "docs.share.public.published"),
179
180 RoleChoices: []roleChoice{
181 {Value: "reader", Title: rastrillo.T(r, "docs.share.role.reader"), Checked: true},
182 {Value: "writer", Title: rastrillo.T(r, "docs.share.role.writer")},
183 },
184 URLLabel: rastrillo.T(r, "docs.share.public.url"),
185 CopyButton: rastrillo.T(r, "docs.share.public.copy"),
186 CopiedLabel: rastrillo.T(r, "docs.share.public.copied"),
187 ShareScript: shareScriptPath(),
188 }
189 if errKey != "" {
190 page.Error = rastrillo.T(r, errKey)
191 }
192 // "Share · {document} · Docs"; the bar names the document, read-only.
193 page.Frame = h.frame(r, frameOptions{titleParts: []string{page.Title, doc.Title}, context: doc.Title})
194
195 // no-store whenever this render carries the plaintext capability.
196 // "Shown once" was a server-side truth only: the browser's disk cache
197 // and bfcache would keep the URL, and any intermediary might, so on a
198 // shared machine the next person could page back to it.
199 // (Re-review, finding 5.)
200 if page.PublicURL != "" {
201 w.Header().Set("Cache-Control", "no-store")
202 }
203 if err := h.render.pageStatus(w, r, "share", page, status); err != nil {
204 h.logger.Error("docs: render share", "sqid", doc.Sqid, "err", err)
205 h.fail(w, r, h.internalFailureStatus(), failStatus)
206 }
207}
208
209// sharePost serves POST /d/{sqid}/share: grant an address access.
210//
211// Scriptless: a plain form with a named address field and a role radio, and a
212// 303 back to this page afterwards so a reload does not re-post.
213func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
214 // Member-only, on top of the owner check below: changing who reaches
215 // a document is a member power. A guest owns no documents (create is
216 // closed to them), so this is belt and braces — and it is the brace
217 // that holds if a guest ever acquires one.
218 if !h.requireMemberRequest(w, r, true) {
219 return
220 }
221 owner, err := h.owner(r)
222 if err != nil {
223 h.logger.Error("docs: resolve owner for share", "err", err)
224 h.fail(w, r, h.internalFailureStatus(), failStatus)
225 return
226 }
227 sqid := r.PathValue("sqid")
228
229 doc, err := h.store.Get(r.Context(), owner, sqid)
230 if err != nil {
231 h.ownerLookupFailure(w, r, err)
232 return
233 }
234
235 role := RoleReader
236 if r.PostFormValue("role") == string(RoleWriter) {
237 role = RoleWriter
238 }
239
240 err = h.store.Grant(r.Context(), owner, sqid, r.PostFormValue("address"), role)
241 switch {
242 case err == nil:
243 if wantsNative(r) {
244 nativeJSON(w, r, 200, map[string]bool{"ok": true})
245 return
246 }
247 http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
248 case errors.Is(err, errEmptyAddress):
249 // Re-render with the message rather than redirecting, so the person
250 // sees what was wrong beside the field they typed it into.
251 grants, listErr := h.store.ListGrants(r.Context(), owner, sqid)
252 if listErr != nil {
253 h.logger.Error("docs: list grants after refusal", "sqid", sqid, "err", listErr)
254 h.fail(w, r, h.internalFailureStatus(), failStatus)
255 return
256 }
257 h.renderShare(w, r, doc, grants, "docs.share.error.address", http.StatusBadRequest)
258 default:
259 h.logger.Error("docs: grant", "sqid", sqid, "err", err)
260 h.fail(w, r, h.internalFailureStatus(), failStatus)
261 }
262}
263
264// shareRevoke serves POST /d/{sqid}/share/revoke.
265func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
266 // Member-only, on top of the owner check below: changing who reaches
267 // a document is a member power. A guest owns no documents (create is
268 // closed to them), so this is belt and braces — and it is the brace
269 // that holds if a guest ever acquires one.
270 if !h.requireMemberRequest(w, r, true) {
271 return
272 }
273 owner, err := h.owner(r)
274 if err != nil {
275 h.logger.Error("docs: resolve owner for revoke", "err", err)
276 h.fail(w, r, h.internalFailureStatus(), failStatus)
277 return
278 }
279 sqid := r.PathValue("sqid")
280
281 if _, err := h.store.Get(r.Context(), owner, sqid); err != nil {
282 h.ownerLookupFailure(w, r, err)
283 return
284 }
285 if err := h.store.Revoke(r.Context(), owner, sqid, r.PostFormValue("address")); err != nil {
286 h.logger.Error("docs: revoke", "sqid", sqid, "err", err)
287 h.fail(w, r, h.internalFailureStatus(), failStatus)
288 return
289 }
290 if wantsNative(r) {
291 nativeJSON(w, r, 200, map[string]bool{"ok": true})
292 return
293 }
294 http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
295}
296
297func (h *handlers) ownerLookupFailure(w http.ResponseWriter, r *http.Request, err error) {
298 if h.store.team != nil && errors.Is(err, errTeamUnavailable) {
299 h.fail(w, r, http.StatusServiceUnavailable, failStatus)
300 return
301 }
302 h.fail(w, r, http.StatusNotFound, failDocument)
303}
304