| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "net/http" |
| 6 | "strconv" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // shareView is the share screen's payload. |
| 11 | type 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. |
| 29 | type 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. |
| 53 | var shareableRoles = []SheetRole{SheetViewer, SheetCommenter, SheetEditor, SheetOwner} |
| 54 | |
| 55 | // handleShare renders who can reach a sheet. |
| 56 | func (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. |
| 69 | func (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. |
| 113 | func 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. |
| 142 | func (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. |
| 170 | func (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. |
| 205 | func (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. |
| 230 | func (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. |
| 259 | func (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 | |
| 268 | func (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 | |