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 "time"
8)
9
10// shareView is the share screen's payload.
11type shareView struct {
12 Sheet SheetSummary
13 People []sharedWith
14 Roles []SheetRole
15 LinkRoles []SheetRole
16 Links []linkRow
17 Notice string
18 // NoticeTone is the callout's rastrillo tone (shared chrome spec §4).
19 // Not sent to the native apps, which read the notice code instead.
20 NoticeTone string `json:"-"`
21 // NewLink is a full URL, shown once. It is not stored anywhere, so
22 // a reload of this screen cannot show it again — which is the
23 // direct consequence of storing only the hash, and the screen says
24 // so rather than letting somebody discover it.
25 NewLink string
26}
27
28// linkRow is one live link, described without its token.
29type linkRow struct {
30 ID uint64
31 Role SheetRole
32 Created time.Time
33}
34
35// shareableRoles is what the screen offers, LEAST able first.
36//
37// The order is the security control. A <select> with no selected option
38// defaults to its first, and this list is rendered straight into one, so
39// whichever role leads is what a person shares by default — including
40// the person who fills in an address, presses Share, and never opens the
41// dropdown at all. Most-able-first made that default "owner": the
42// quickest possible path through this screen handed the sheet away, and
43// the only repair is another owner taking it back.
44//
45// Least-able-first makes the same path grant "viewer", and anything
46// more has to be chosen. That is the same instinct the spec applies to
47// link sharing, which is viewer-or-commenter and never editor.
48//
49// Owner is still offered: handing a sheet on is a real thing to want,
50// and leaving it out means the only way to transfer one is a database
51// edit. It is last because it is the one that cannot be undone by the
52// person who did it.
53var shareableRoles = []SheetRole{SheetViewer, SheetCommenter, SheetEditor, SheetOwner}
54
55// handleShare renders who can reach a sheet.
56func (a *App) handleShare(w http.ResponseWriter, r *http.Request) {
57 s, viewer, ok := a.sheetForSharing(w, r)
58 if !ok {
59 return
60 }
61 a.renderShare(w, r, s, viewer, r.URL.Query().Get("notice"), "")
62}
63
64// renderShare draws the screen. newLink is a token in readable form,
65// and it arrives as an argument rather than out of the request for the
66// reason handleLinkCreate gives: the only caller that has one is the
67// handler that just minted it, in the same process, on the same
68// goroutine. Nothing that crossed a URL can reach this parameter.
69func (a *App) renderShare(w http.ResponseWriter, r *http.Request, s SheetSummary, _ Member, notice, newLink string) {
70 people, err := a.grantsOn(s.ID)
71 if err != nil {
72 a.fail(w, r, err)
73 return
74 }
75 links, err := a.linksOn(s.ID)
76 if err != nil {
77 a.fail(w, r, err)
78 return
79 }
80 rows := make([]linkRow, 0, len(links))
81 for _, l := range links {
82 rows = append(rows, linkRow{ID: l.ID, Role: SheetRole(l.Role), Created: l.CreatedAt})
83 }
84
85 view := shareView{
86 Sheet: s, People: people, Roles: shareableRoles,
87 LinkRoles: linkRoles, Links: rows, Notice: noticeText(notice),
88 }
89 if notice == noticeRevokedTeam {
90 view.Notice = a.revokedTeamNotice(r, s.ID)
91 }
92 view.NoticeTone = noticeTone(notice)
93 if newLink != "" {
94 view.NewLink = a.linkURL(newLink)
95 // The new link's own callout says "copy it now" with the link
96 // in it; a second callout above it saying the same thing is the
97 // duplicate the audit found (§4: keep one, a warning).
98 if notice == "link" {
99 view.Notice = ""
100 }
101 }
102 f := onSheet(s, "Share")
103 f.ret = "/sheets/" + strconv.FormatUint(s.ID, 10) + "/share"
104 a.render(w, r, "share", f, http.StatusOK, view)
105}
106
107// noticeText maps a code to a fixed sentence.
108//
109// A code, never the text itself: the value comes from a query string,
110// and a page that echoed it would take a redirect parameter straight
111// into the document. This is the same shape signinMessage uses, for the
112// same reason.
113func noticeText(code string) string {
114 switch code {
115 case "shared":
116 return "Shared. They can sign in with that address to open it."
117 case "revoked":
118 return "Access removed."
119 case noticeRevokedTeam:
120 // renderShare replaces this with the named version when it can
121 // resolve who the revoke was about. This is the honest sentence
122 // when it cannot.
123 return revokedTeamText
124 case "bad-address":
125 return "That does not look like an email address."
126 case "bad-role":
127 return "Unknown role."
128 case "last-owner":
129 return "A sheet needs an owner. Make somebody else the owner first."
130 case "link":
131 return "Link created. Copy it now — it is not stored, so this is the only time it can be shown."
132 case "link-revoked":
133 return "Link turned off. Anyone holding it now gets nothing."
134 case "bad-link-role":
135 return "A link can only view or comment, never edit."
136 default:
137 return ""
138 }
139}
140
141// handleShareGrant adds or changes somebody's access.
142func (a *App) handleShareGrant(w http.ResponseWriter, r *http.Request) {
143 s, viewer, ok := a.sheetForSharing(w, r)
144 if !ok {
145 return
146 }
147 role := SheetRole(r.FormValue("role"))
148 if !role.Valid() {
149 a.shareRedirect(w, r, s, "bad-role")
150 return
151 }
152 if _, err := a.grant(s.ID, r.FormValue("address"), role, viewer); err != nil {
153 // Demoting the last owner is refused, and says so. Told
154 // "that does not look like an email address" instead, a person
155 // would retype their own address and try again — the one
156 // response that cannot help.
157 if errors.Is(err, errLastOwner) {
158 a.shareRedirect(w, r, s, "last-owner")
159 return
160 }
161 // The address is not echoed back. It is somebody's email, and
162 // an error string is a log line waiting to happen.
163 a.shareRedirect(w, r, s, "bad-address")
164 return
165 }
166 a.shareRedirect(w, r, s, "shared")
167}
168
169// handleShareRevoke removes somebody's access.
170func (a *App) handleShareRevoke(w http.ResponseWriter, r *http.Request) {
171 s, _, ok := a.sheetForSharing(w, r)
172 if !ok {
173 return
174 }
175 memberID, err := strconv.ParseUint(r.FormValue("member"), 10, 64)
176 if err != nil || memberID == 0 {
177 a.notFound(w, r)
178 return
179 }
180 // A sheet with no owner is a sheet nobody can ever share again, and
181 // there is no admin path to repair it. Refusing the last one is the
182 // difference between a mistake and an unrecoverable mistake.
183 //
184 // The check is inside revoke's own transaction rather than here.
185 // Here it was a read separated from its delete by the rest of this
186 // handler, so two owners revoking each other at the same moment both
187 // saw two owners and both deleted.
188 if err := a.revoke(s.ID, memberID); err != nil {
189 if errors.Is(err, errLastOwner) {
190 a.shareRedirect(w, r, s, "last-owner")
191 return
192 }
193 a.fail(w, r, err)
194 return
195 }
196 if a.stillReaches(s.ID, memberID) {
197 a.shareRedirectAbout(w, r, s, noticeRevokedTeam, memberID)
198 return
199 }
200 a.shareRedirect(w, r, s, "revoked")
201}
202
203// wouldOrphan reports whether removing this grant would leave the sheet
204// with no owner at all.
205func (a *App) wouldOrphan(sheetID, memberID uint64) bool {
206 people, err := a.grantsOn(sheetID)
207 if err != nil {
208 return true // cannot tell means do not risk it
209 }
210 var owners, losing int
211 for _, p := range people {
212 if p.Role != SheetOwner {
213 continue
214 }
215 owners++
216 if p.Member.ID == memberID {
217 losing++
218 }
219 }
220 return owners-losing < 1
221}
222
223// sheetForSharing loads the sheet and proves the viewer may change who
224// can reach it.
225//
226// Sheet owners only. Changing who else can read a sheet is the one
227// power the people it affects cannot undo, so it does not come with
228// team standing — see roleFor, which hands colleagues editor and not
229// owner for exactly this reason.
230func (a *App) sheetForSharing(w http.ResponseWriter, r *http.Request) (SheetSummary, Member, bool) {
231 s, ok := a.sheetFromURL(w, r)
232 if !ok {
233 return SheetSummary{}, Member{}, false
234 }
235 viewer, ok := a.actor(r)
236 if !ok {
237 a.notFound(w, r)
238 return SheetSummary{}, Member{}, false
239 }
240 if !a.mayShare(s.ID, viewer) {
241 // 404, not 403: somebody who can read a sheet but not share it
242 // learns nothing new, and somebody who can do neither learns
243 // nothing at all.
244 a.notFound(w, r)
245 return SheetSummary{}, Member{}, false
246 }
247 return s, viewer, true
248}
249
250// shareRedirect answers a POST with a 303 back to the screen.
251//
252// The notice is a fixed code, and the sheet id is re-formatted from a
253// uint64 rather than passed through — both end up in a Location header,
254// and a number that came back out as a number cannot carry anything
255// else.
256// shareRedirectAbout is shareRedirect for a notice that has to name
257// somebody. It carries the member id and never the address: an address
258// in a request line is an address in the platform's access logs.
259func (a *App) shareRedirectAbout(w http.ResponseWriter, r *http.Request, s SheetSummary,
260 notice string, memberID uint64) {
261 if nativeNotice(w, r, notice) {
262 return
263 }
264 http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/share?notice="+notice+
265 "&who="+strconv.FormatUint(memberID, 10), http.StatusSeeOther)
266}
267
268func (a *App) shareRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary, notice string) {
269 if nativeNotice(w, r, notice) {
270 return
271 }
272 http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/share?notice="+notice,
273 http.StatusSeeOther)
274}
275