| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | type 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. |
| 48 | func (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. |
| 109 | func (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. |
| 126 | func (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. |
| 149 | func (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. |
| 173 | func (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 | |