feat: link sharing — hashed tokens, never editor
Spec 8.3. Off unless somebody makes one, 32 bytes from crypto/rand, revocable, and viewer-or-commenter only. **Only the hash is stored.** Plaintext, the replicated database becomes a bundle of live read capabilities to sealed sheets — rule zero defeated through a side door for the second time, after blobs. The token is shown once and the screen says why it cannot be shown again. A test greps the row to prove it. SHA-256, not bcrypt, and the difference from a password is the point: a password is low-entropy and needs a slow hash to survive guessing; this is 256 bits, so guessing is not a threat model and stretching would buy nothing while making every page load do the work. Never editor (ruling 12) — every version row carries an actor, and an anonymous editor would write unattributed rows into version history, which the history model cannot represent. The link view renders the scriptless table, not the grid, and that is the honest shape rather than a shortcut: the grid's data comes from session-gated endpoints, and its stream would put a link holder in the fan-out while 8.3 rules they get no presence at all. Read-only is expressed in the data — the handler passes a row and column no cell can equal — so the view cannot grow an edit form by somebody adding a template branch. Every failure answers 404: a token that never existed, one that was revoked, and one whose sheet is gone are one answer, or the route is an oracle for which tokens were ever real. Revoking sets RevokedAt rather than deleting, so somebody holding a stale URL can be told the link was turned off rather than that it never existed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
12 files changed,
+786
−29
internal/sheets/app.go+7 −0internal/sheets/handlers_link.go+164 −0internal/sheets/handlers_share.go+49 −7internal/sheets/handlers_table.go+34 −21internal/sheets/migrations.go+1 −1internal/sheets/migrations/0005_create_share_links.sql+6 −0internal/sheets/migrations/schema.sql+6 −0internal/sheets/models.go+34 −0internal/sheets/sharelink.go+135 −0internal/sheets/sharelink_test.go+253 −0internal/sheets/templates/link.html+52 −0internal/sheets/templates/share.html+45 −0
CI passed — run details
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 78bf53c..3a4db0e 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -123,6 +123,11 @@ func (a *App) Router() http.Handler { |
| // served by being told to enable JavaScript first. |
| r.Post("/mode", a.handleMode) |
| + // A share link is a capability, so this route is deliberately |
| + // OUTSIDE the session group: no account, no member, no directory |
| + // entry (spec §8.3). The token is the entire authorization. |
| + r.Get("/l/{token}", a.handleLinkView) |
| + |
| if a.auth != nil { |
| // The four handlers rastrillo/auth expects an app to mount, at |
| // the paths its own docs use. Verify reads ?token=; the path is |
| @@ -148,6 +153,8 @@ func (a *App) Router() http.Handler { |
| r.Get("/sheets/{sheetID}/share", a.handleShare) |
| r.Post("/sheets/{sheetID}/share", a.handleShareGrant) |
| r.Post("/sheets/{sheetID}/share/revoke", a.handleShareRevoke) |
| + r.Post("/sheets/{sheetID}/share/link", a.handleLinkCreate) |
| + r.Post("/sheets/{sheetID}/share/link/revoke", a.handleLinkRevoke) |
| // The grid's own API. Under /api because it answers JSON and |
| // carries no chrome; behind the same session as everything |
diff --git a/internal/sheets/handlers_link.go b/internal/sheets/handlers_link.go| new file mode 100644 |
| index 0000000..f82b183 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_link.go |
| @@ -0,0 +1,164 @@ |
| +package sheets |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// linkView is the anonymous read-only page. |
| +type linkView struct { |
| + Sheet SheetSummary |
| + Rows []tableRow |
| + Cols []string |
| + Page int |
| + Pages int |
| + |
| + HasPrev bool |
| + HasNext bool |
| + PrevPage int |
| + NextPage int |
| + Token string |
| + |
| + // CanComment is true for a commenter link. The link itself still |
| + // buys only reading — spec §8.3: the moment somebody wants to |
| + // write, they sign in, which is what mints a guest member and |
| + // produces a directory entry. Comments are a later slice; this |
| + // field is what that slice will read, and until then it decides |
| + // nothing but a sentence on the page. |
| + CanComment bool |
| +} |
| + |
| +// handleLinkView serves a sheet to whoever holds a link token. |
| +// |
| +// No session, no member, no directory entry — spec §8.3: a view link |
| +// mints nothing. It is a capability, and the only thing it can do is |
| +// read. |
| +// |
| +// It renders the scriptless table rather than the grid, and that is the |
| +// honest shape rather than a shortcut. The grid's data comes from |
| +// session-gated endpoints, and its live stream would put a link holder |
| +// in the fan-out — while §8.3 rules that a link viewer gets no presence |
| +// cursor and no name in the presence stack, because presence is a write |
| +// to shared awareness state. |
| +func (a *App) handleLinkView(w http.ResponseWriter, r *http.Request) { |
| + token := chi.URLParam(r, "token") |
| + link, ok := a.linkFor(token) |
| + if !ok { |
| + // One answer for a token that never existed, one that was |
| + // revoked, and one whose sheet is gone. Distinguishing them |
| + // would turn this route into an oracle for which tokens were |
| + // ever real. |
| + a.notFound(w, r) |
| + return |
| + } |
| + |
| + s, err := a.summarizeByID(link.SheetID) |
| + if err != nil { |
| + a.notFound(w, r) |
| + return |
| + } |
| + |
| + h, err := a.openTabFor(s) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + defer h.Release() |
| + |
| + page := intParam(r, "page") |
| + if page < 1 { |
| + page = 1 |
| + } |
| + pages := (maxWindowRows + tableRowsPerPage - 1) / tableRowsPerPage |
| + if page > pages { |
| + page = pages |
| + } |
| + |
| + // editR/editC of -1: no cell is ever editable here. The template |
| + // shares tableRow with the signed-in view, and passing a coordinate |
| + // that can never match is how "read-only" is expressed in the data |
| + // rather than trusted to the template. |
| + view := linkView{ |
| + Sheet: s, Page: page, Pages: pages, |
| + HasPrev: page > 1, HasNext: page < pages, |
| + PrevPage: page - 1, NextPage: page + 1, |
| + Token: token, |
| + CanComment: SheetRole(link.Role) == SheetCommenter, |
| + } |
| + for c := 0; c < tableCols; c++ { |
| + view.Cols = append(view.Cols, colLabel(c)) |
| + } |
| + view.Rows = a.tableRows(h, (page-1)*tableRowsPerPage, -1, -1) |
| + |
| + a.render(w, r, "link", s.Title, http.StatusOK, view) |
| +} |
| + |
| +// summarizeByID reads a sheet without a grant check. |
| +// |
| +// It exists for exactly one caller — the link route, where the token IS |
| +// the authorization — and is deliberately not named sheetSummary, so |
| +// nothing reaches for it by accident from a route that has a viewer and |
| +// should be asking about their grant. |
| +func (a *App) summarizeByID(id uint64) (SheetSummary, error) { |
| + if a.db == nil { |
| + return SheetSummary{}, errNoDB |
| + } |
| + if a.sealer == nil { |
| + return SheetSummary{}, errNoSealer |
| + } |
| + var sheet store.Sheet |
| + if err := a.db.Where("id = ? AND deleted_at IS NULL", id).First(&sheet).Error; err != nil { |
| + return SheetSummary{}, err |
| + } |
| + return a.summarize(sheet) |
| +} |
| + |
| +// linkURL is the address to hand somebody. Built here rather than in a |
| +// template so the origin comes from configuration and not from whatever |
| +// Host header the request arrived with. |
| +func (a *App) linkURL(token string) string { |
| + return a.origin + "/l/" + token |
| +} |
| + |
| +// handleLinkCreate mints a link and shows the token once. |
| +func (a *App) handleLinkCreate(w http.ResponseWriter, r *http.Request) { |
| + s, viewer, ok := a.sheetForSharing(w, r) |
| + if !ok { |
| + return |
| + } |
| + role := SheetRole(r.FormValue("role")) |
| + token, err := a.createLink(s.ID, role, viewer) |
| + if err != nil { |
| + a.shareRedirect(w, r, s, "bad-link-role") |
| + return |
| + } |
| + // The token travels in the redirect because this is the only moment |
| + // it exists in readable form: it is not stored, so a reload of the |
| + // share screen cannot show it again. Putting it in the URL is a |
| + // real trade — it lands in browser history — and it is the smaller |
| + // cost against storing the token or showing it never. |
| + http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+ |
| + "/share?notice=link&token="+token, http.StatusSeeOther) |
| +} |
| + |
| +// handleLinkRevoke turns a link off. |
| +func (a *App) handleLinkRevoke(w http.ResponseWriter, r *http.Request) { |
| + s, _, ok := a.sheetForSharing(w, r) |
| + if !ok { |
| + return |
| + } |
| + linkID, err := strconv.ParseUint(r.FormValue("link"), 10, 64) |
| + if err != nil || linkID == 0 { |
| + a.notFound(w, r) |
| + return |
| + } |
| + if err := a.revokeLink(s.ID, linkID); err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + a.shareRedirect(w, r, s, "link-revoked") |
| +} |
diff --git a/internal/sheets/handlers_share.go b/internal/sheets/handlers_share.go| index 21c9c42..e86d2e7 100644 |
| --- a/internal/sheets/handlers_share.go |
| +++ b/internal/sheets/handlers_share.go |
| @@ -3,14 +3,29 @@ package sheets |
| import ( |
| "net/http" |
| "strconv" |
| + "time" |
| ) |
| // shareView is the share screen's payload. |
| type shareView struct { |
| - Sheet SheetSummary |
| - People []sharedWith |
| - Roles []SheetRole |
| - Notice string |
| + Sheet SheetSummary |
| + People []sharedWith |
| + Roles []SheetRole |
| + LinkRoles []SheetRole |
| + Links []linkRow |
| + Notice string |
| + // NewLink is a full URL, shown once. It is not stored anywhere, so |
| + // a reload of this screen cannot show it again — which is the |
| + // direct consequence of storing only the hash, and the screen says |
| + // so rather than letting somebody discover it. |
| + NewLink string |
| +} |
| + |
| +// linkRow is one live link, described without its token. |
| +type linkRow struct { |
| + ID uint64 |
| + Role SheetRole |
| + Created time.Time |
| } |
| // shareableRoles is what the screen offers, most able first. |
| @@ -35,9 +50,30 @@ func (a *App) renderShare(w http.ResponseWriter, r *http.Request, s SheetSummary |
| a.fail(w, r, err) |
| return |
| } |
| - a.render(w, r, "share", "Sharing "+s.Title, http.StatusOK, shareView{ |
| - Sheet: s, People: people, Roles: shareableRoles, Notice: noticeText(notice), |
| - }) |
| + links, err := a.linksOn(s.ID) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + rows := make([]linkRow, 0, len(links)) |
| + for _, l := range links { |
| + rows = append(rows, linkRow{ID: l.ID, Role: SheetRole(l.Role), Created: l.CreatedAt}) |
| + } |
| + |
| + view := shareView{ |
| + Sheet: s, People: people, Roles: shareableRoles, |
| + LinkRoles: linkRoles, Links: rows, Notice: noticeText(notice), |
| + } |
| + // Only ever from this request's own query, and only right after |
| + // minting one. It is echoed into a page, so it is checked against |
| + // the token that was actually just created rather than trusted: |
| + // hashing it and finding a live link for this sheet is the test. |
| + if tok := r.URL.Query().Get("token"); tok != "" { |
| + if l, ok := a.linkFor(tok); ok && l.SheetID == s.ID { |
| + view.NewLink = a.linkURL(tok) |
| + } |
| + } |
| + a.render(w, r, "share", "Sharing "+s.Title, http.StatusOK, view) |
| } |
| // noticeText maps a code to a fixed sentence. |
| @@ -58,6 +94,12 @@ func noticeText(code string) string { |
| return "Unknown role." |
| case "last-owner": |
| return "A sheet needs an owner. Make somebody else the owner first." |
| + case "link": |
| + return "Link created. Copy it now — it is not stored, so this is the only time it can be shown." |
| + case "link-revoked": |
| + return "Link turned off. Anyone holding it now gets nothing." |
| + case "bad-link-role": |
| + return "A link can only view or comment, never edit." |
| default: |
| return "" |
| } |
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go| index cc49d50..491c126 100644 |
| --- a/internal/sheets/handlers_table.go |
| +++ b/internal/sheets/handlers_table.go |
| @@ -80,7 +80,6 @@ func (a *App) handleTable(w http.ResponseWriter, r *http.Request) { |
| } |
| editR, editC := parseEdit(r.URL.Query().Get("edit")) |
| - idx := newPosIndex(h) |
| first := (page - 1) * tableRowsPerPage |
| view := tableView{ |
| @@ -91,27 +90,9 @@ func (a *App) handleTable(w http.ResponseWriter, r *http.Request) { |
| for c := 0; c < tableCols; c++ { |
| view.Cols = append(view.Cols, colLabel(c)) |
| } |
| - for i := 0; i < tableRowsPerPage; i++ { |
| - rowIdx := first + i |
| - row := tableRow{Number: rowIdx + 1} |
| - for c := 0; c < tableCols; c++ { |
| - cell := tableCell{ |
| - R: rowIdx, C: c, |
| - Ref: colLabel(c) + strconv.Itoa(rowIdx+1), |
| - Editing: rowIdx == editR && c == editC, |
| - } |
| - if rowID, okR := idx.rowID[rowIdx]; okR { |
| - if colID, okC := idx.colID[c]; okC { |
| - cell.Raw, _ = h.RawInput(rowID, colID) |
| - cell.Display = display(h.Value(rowID, colID), h.Format(rowID, colID)) |
| - } |
| - } |
| - row.Cells = append(row.Cells, cell) |
| - } |
| - view.Rows = append(view.Rows, row) |
| - } |
| + view.Rows = a.tableRows(h, first, editR, editC) |
| - view.Warned = warnedCells(h, idx, maxWindowRows, tableCols) |
| + view.Warned = warnedCells(h, newPosIndex(h), maxWindowRows, tableCols) |
| a.render(w, r, "table", s.Title+" — table view", http.StatusOK, view) |
| } |
| @@ -232,3 +213,35 @@ func colLabel(n int) string { |
| } |
| return label |
| } |
| + |
| +// tableRows builds one page of rows. |
| +// |
| +// Shared with the anonymous link view, which passes editR/editC of -1 |
| +// so no cell can ever match and none renders a form. Expressing |
| +// read-only as a coordinate nothing equals — rather than as a flag the |
| +// template is trusted to honour — means the read-only view cannot grow |
| +// an edit form by somebody adding a branch to the template. |
| +func (a *App) tableRows(h *store.Handle, first, editR, editC int) []tableRow { |
| + idx := newPosIndex(h) |
| + rows := make([]tableRow, 0, tableRowsPerPage) |
| + for i := 0; i < tableRowsPerPage; i++ { |
| + rowIdx := first + i |
| + row := tableRow{Number: rowIdx + 1} |
| + for c := 0; c < tableCols; c++ { |
| + cell := tableCell{ |
| + R: rowIdx, C: c, |
| + Ref: colLabel(c) + strconv.Itoa(rowIdx+1), |
| + Editing: rowIdx == editR && c == editC, |
| + } |
| + if rowID, okR := idx.rowID[rowIdx]; okR { |
| + if colID, okC := idx.colID[c]; okC { |
| + cell.Raw, _ = h.RawInput(rowID, colID) |
| + cell.Display = display(h.Value(rowID, colID), h.Format(rowID, colID)) |
| + } |
| + } |
| + row.Cells = append(row.Cells, cell) |
| + } |
| + rows = append(rows, row) |
| + } |
| + return rows |
| +} |
diff --git a/internal/sheets/migrations.go b/internal/sheets/migrations.go| index f18e6eb..1525419 100644 |
| --- a/internal/sheets/migrations.go |
| +++ b/internal/sheets/migrations.go |
| @@ -50,5 +50,5 @@ var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema) |
| // moved. |
| var Models = []any{ |
| store.Sheet{}, store.Tab{}, store.Row{}, store.Col{}, |
| - store.Cell{}, store.Format{}, store.Event{}, Member{}, Grant{}, |
| + store.Cell{}, store.Format{}, store.Event{}, Member{}, Grant{}, ShareLink{}, |
| } |
diff --git a/internal/sheets/migrations/0005_create_share_links.sql b/internal/sheets/migrations/0005_create_share_links.sql| new file mode 100644 |
| index 0000000..75dbd0f |
| --- /dev/null |
| +++ b/internal/sheets/migrations/0005_create_share_links.sql |
| @@ -0,0 +1,6 @@ |
| +CREATE TABLE `share_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`sheet_id` integer,`token_hash` text,`role` text,`created_by` integer,`created_at` datetime,`revoked_at` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_share_links_token_hash` ON `share_links`(`token_hash`); |
| + |
| +CREATE INDEX `idx_share_links_sheet_id` ON `share_links`(`sheet_id`); |
| + |
diff --git a/internal/sheets/migrations/schema.sql b/internal/sheets/migrations/schema.sql| index 5feda3e..b8b0bcc 100644 |
| --- a/internal/sheets/migrations/schema.sql |
| +++ b/internal/sheets/migrations/schema.sql |
| @@ -15,6 +15,8 @@ CREATE TABLE `members` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text,`c |
| CREATE TABLE `rows` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`position` integer,`height` integer); |
| +CREATE TABLE `share_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`sheet_id` integer,`token_hash` text,`role` text,`created_by` integer,`created_at` datetime,`revoked_at` datetime); |
| + |
| CREATE TABLE `sheets` (`id` integer PRIMARY KEY AUTOINCREMENT,`title_sealed` blob,`created_by` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime); |
| CREATE TABLE `tabs` (`id` integer PRIMARY KEY AUTOINCREMENT,`sheet_id` integer,`name_sealed` blob,`position` integer,`frozen_rows` integer,`frozen_cols` integer); |
| @@ -35,6 +37,10 @@ CREATE UNIQUE INDEX `idx_members_address` ON `members`(`address`); |
| CREATE UNIQUE INDEX `idx_row_tab_pos` ON `rows`(`tab_id`,`position`); |
| +CREATE INDEX `idx_share_links_sheet_id` ON `share_links`(`sheet_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_share_links_token_hash` ON `share_links`(`token_hash`); |
| + |
| CREATE INDEX `idx_sheets_deleted_at` ON `sheets`(`deleted_at`); |
| CREATE INDEX `idx_tabs_sheet_id` ON `tabs`(`sheet_id`); |
diff --git a/internal/sheets/models.go b/internal/sheets/models.go| index a75b7de..ebe2002 100644 |
| --- a/internal/sheets/models.go |
| +++ b/internal/sheets/models.go |
| @@ -110,3 +110,37 @@ type Grant struct { |
| CreatedAt time.Time |
| UpdatedAt time.Time |
| } |
| + |
| +// ShareLink is a capability: whoever holds the token may read the |
| +// sheet, with no account and no session. |
| +// |
| +// **TokenHash, never the token.** Spec §8.3, following Pegamento's |
| +// service contract: stored plaintext, the replicated database becomes a |
| +// bundle of live read capabilities to sealed sheets — §0 defeated |
| +// through a side door for the second time. So the token is shown once, |
| +// at creation, and never again; what is stored is only enough to |
| +// recognise it coming back. |
| +// |
| +// The hash is SHA-256 rather than bcrypt or argon2, and that is a |
| +// deliberate difference from how a password would be stored. A password |
| +// is low-entropy and needs a slow hash to survive being guessed. This |
| +// token is 32 bytes from crypto/rand, so guessing is not a threat model |
| +// and a slow hash would buy nothing while making every page load do |
| +// key-stretching work. The property that matters here — the stored form |
| +// does not yield the token — a fast hash gives just as completely. |
| +// |
| +// RevokedAt rather than a delete, because "this link was turned off" |
| +// and "this link never existed" are different answers to give somebody |
| +// holding a stale URL, and the first is the kinder one. |
| +type ShareLink struct { |
| + ID uint64 `gorm:"primaryKey"` |
| + SheetID uint64 `gorm:"index"` |
| + TokenHash string `gorm:"uniqueIndex;size:64"` |
| + // Role is viewer or commenter, never editor (ruling 12). Every |
| + // version row carries an actor, and an anonymous editor would write |
| + // unattributed rows into version history. |
| + Role string `gorm:"size:16"` |
| + CreatedBy uint64 |
| + CreatedAt time.Time |
| + RevokedAt *time.Time |
| +} |
diff --git a/internal/sheets/sharelink.go b/internal/sheets/sharelink.go| new file mode 100644 |
| index 0000000..1c88967 |
| --- /dev/null |
| +++ b/internal/sheets/sharelink.go |
| @@ -0,0 +1,135 @@ |
| +package sheets |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/subtle" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "errors" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// linkTokenBytes is the token's entropy: 32 bytes from crypto/rand. |
| +// |
| +// "High-entropy" is spec §8.3's word and this is what it has to mean in |
| +// practice — 256 bits, so the token cannot be guessed, enumerated, or |
| +// found by walking a keyspace. It is also why the stored hash can be a |
| +// fast one: see ShareLink's doc comment. |
| +const linkTokenBytes = 32 |
| + |
| +// linkRoles are the only roles a link may carry. |
| +// |
| +// Never editor (ruling 12). Every version row carries an actor, and an |
| +// anonymous editor would write unattributed rows into version history — |
| +// which is not a policy preference but a thing the history model cannot |
| +// represent. |
| +var linkRoles = []SheetRole{SheetViewer, SheetCommenter} |
| + |
| +func linkRoleValid(r SheetRole) bool { |
| + for _, ok := range linkRoles { |
| + if r == ok { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// newLinkToken mints a token and returns it with its hash. The token is |
| +// returned exactly once, to be shown exactly once. |
| +func newLinkToken() (token, hash string, err error) { |
| + buf := make([]byte, linkTokenBytes) |
| + if _, err := rand.Read(buf); err != nil { |
| + return "", "", err |
| + } |
| + token = base64.RawURLEncoding.EncodeToString(buf) |
| + return token, hashLinkToken(token), nil |
| +} |
| + |
| +// hashLinkToken is what gets stored and what a lookup compares. |
| +func hashLinkToken(token string) string { |
| + sum := sha256.Sum256([]byte(token)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// createLink mints a link for a sheet and returns the token, which the |
| +// caller must show once and then forget. |
| +func (a *App) createLink(sheetID uint64, role SheetRole, by Member) (string, error) { |
| + if a.db == nil { |
| + return "", errNoDB |
| + } |
| + if !linkRoleValid(role) { |
| + // Named rather than silently downgraded: somebody asking for an |
| + // editor link has a expectation this refuses, and refusing |
| + // quietly would leave them thinking they made one. |
| + return "", errors.New("sheets: a link can only view or comment, never edit") |
| + } |
| + token, hash, err := newLinkToken() |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := a.db.Create(&ShareLink{ |
| + SheetID: sheetID, TokenHash: hash, Role: string(role), CreatedBy: by.ID, |
| + }).Error; err != nil { |
| + return "", err |
| + } |
| + return token, nil |
| +} |
| + |
| +// linksOn lists a sheet's links for the share screen. The tokens are |
| +// not among them — they cannot be, which is the point. |
| +func (a *App) linksOn(sheetID uint64) ([]ShareLink, error) { |
| + if a.db == nil { |
| + return nil, errNoDB |
| + } |
| + var rows []ShareLink |
| + err := a.db.Where("sheet_id = ? AND revoked_at IS NULL", sheetID). |
| + Order("id ASC").Find(&rows).Error |
| + return rows, err |
| +} |
| + |
| +// revokeLink turns a link off. The row survives so that somebody |
| +// holding a stale URL can be told the link was turned off rather than |
| +// that it never existed. |
| +func (a *App) revokeLink(sheetID, linkID uint64) error { |
| + if a.db == nil { |
| + return errNoDB |
| + } |
| + now := time.Now().UTC() |
| + return a.db.Model(&ShareLink{}). |
| + Where("id = ? AND sheet_id = ? AND revoked_at IS NULL", linkID, sheetID). |
| + Update("revoked_at", now).Error |
| +} |
| + |
| +// linkFor resolves a token to its live link. |
| +// |
| +// The lookup is by hash, so the token never has to be compared against |
| +// anything stored. The constant-time comparison afterwards is belt and |
| +// braces on an already-indexed exact match: it costs nothing and |
| +// removes any argument about the index leaking timing. |
| +func (a *App) linkFor(token string) (ShareLink, bool) { |
| + if a.db == nil || token == "" { |
| + return ShareLink{}, false |
| + } |
| + hash := hashLinkToken(token) |
| + var link ShareLink |
| + err := a.db.Where("token_hash = ?", hash).First(&link).Error |
| + if err != nil { |
| + if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + return ShareLink{}, false |
| + } |
| + return ShareLink{}, false |
| + } |
| + if subtle.ConstantTimeCompare([]byte(link.TokenHash), []byte(hash)) != 1 { |
| + return ShareLink{}, false |
| + } |
| + if link.RevokedAt != nil { |
| + return ShareLink{}, false |
| + } |
| + if !a.sheetExists(link.SheetID) { |
| + return ShareLink{}, false |
| + } |
| + return link, true |
| +} |
diff --git a/internal/sheets/sharelink_test.go b/internal/sheets/sharelink_test.go| new file mode 100644 |
| index 0000000..21782af |
| --- /dev/null |
| +++ b/internal/sheets/sharelink_test.go |
| @@ -0,0 +1,253 @@ |
| +package sheets |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The one rule, through a side door. Spec §8.3: stored plaintext, the |
| +// replicated database becomes a bundle of live read capabilities to |
| +// sealed sheets. |
| +func TestALinkTokenIsNeverStored(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app)) |
| + if err != nil { |
| + t.Fatalf("createLink: %v", err) |
| + } |
| + if len(token) < 40 { |
| + t.Fatalf("token is %d characters; 32 random bytes should be longer", len(token)) |
| + } |
| + |
| + var stored string |
| + if err := app.db.Raw("SELECT token_hash FROM share_links LIMIT 1").Row().Scan(&stored); err != nil { |
| + t.Fatalf("read back: %v", err) |
| + } |
| + if stored == token { |
| + t.Fatal("the token itself is in the database") |
| + } |
| + if strings.Contains(stored, token) || strings.Contains(token, stored) { |
| + t.Fatal("the stored value contains the token") |
| + } |
| + if stored != hashLinkToken(token) { |
| + t.Fatal("the stored value is not the token's hash") |
| + } |
| +} |
| + |
| +func TestTwoLinksNeverCollide(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + me := signedInMember(t, app) |
| + |
| + seen := map[string]bool{} |
| + for i := 0; i < 20; i++ { |
| + token, err := app.createLink(s.ID, SheetViewer, me) |
| + if err != nil { |
| + t.Fatalf("createLink: %v", err) |
| + } |
| + if seen[token] { |
| + t.Fatal("crypto/rand produced the same token twice") |
| + } |
| + seen[token] = true |
| + } |
| +} |
| + |
| +// Ruling 12: never editor. Every version row carries an actor, and an |
| +// anonymous editor would write unattributed rows into version history. |
| +func TestALinkCanNeverEdit(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + me := signedInMember(t, app) |
| + |
| + for _, role := range []SheetRole{SheetEditor, SheetOwner, SheetRole("admin"), SheetRole("")} { |
| + if _, err := app.createLink(s.ID, role, me); err == nil { |
| + t.Errorf("a %q link was created", role) |
| + } |
| + } |
| + for _, role := range []SheetRole{SheetViewer, SheetCommenter} { |
| + if _, err := app.createLink(s.ID, role, me); err != nil { |
| + t.Errorf("a %q link was refused: %v", role, err) |
| + } |
| + } |
| +} |
| + |
| +// A view link mints nothing: no session, no member, no directory entry. |
| +func TestALinkOpensTheSheetWithNoAccount(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=6*7"}`) |
| + |
| + token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app)) |
| + if err != nil { |
| + t.Fatalf("createLink: %v", err) |
| + } |
| + |
| + // No cookies at all. |
| + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /l/<token> = %d, want 200", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + if !strings.Contains(body, "Budget") { |
| + t.Error("the link page does not show the sheet's title") |
| + } |
| + if !strings.Contains(body, ">42<") { |
| + t.Error("the link page does not show the sheet's computed values") |
| + } |
| + |
| + var members int64 |
| + app.db.Model(&Member{}).Count(&members) |
| + // Only the signed-in owner. Opening a link must not mint anybody. |
| + if members != 1 { |
| + t.Fatalf("opening a link left %d members, want 1 — a view link mints nothing", members) |
| + } |
| +} |
| + |
| +// The link page must offer no way to write, and the write routes must |
| +// refuse a link holder, who has no session at all. |
| +func TestALinkHolderCannotWrite(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + token, err := app.createLink(s.ID, SheetCommenter, signedInMember(t, app)) |
| + if err != nil { |
| + t.Fatalf("createLink: %v", err) |
| + } |
| + |
| + body := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)).Body.String() |
| + if strings.Contains(body, "<form") { |
| + t.Error("the link page carries a form; a link is read-only") |
| + } |
| + if strings.Contains(body, "edit=") { |
| + t.Error("the link page offers an edit link") |
| + } |
| + |
| + // And the real write routes, with no session. |
| + if rec := postCell(t, app, nil, cells, `{"r":0,"c":0,"raw":"sneaky"}`); rec.Code == http.StatusOK { |
| + t.Error("a cell was written with no session") |
| + } |
| + form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"sneaky"}, "page": {"1"}} |
| + if rec := do(t, app, postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form)); rec.Code == http.StatusSeeOther { |
| + t.Error("the scriptless table accepted a write with no session") |
| + } |
| +} |
| + |
| +func TestARevokedLinkGivesNothing(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app)) |
| + if err != nil { |
| + t.Fatalf("createLink: %v", err) |
| + } |
| + if rec := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)); rec.Code != http.StatusOK { |
| + t.Fatalf("the link did not work before revoking: %d", rec.Code) |
| + } |
| + |
| + links, err := app.linksOn(s.ID) |
| + if err != nil || len(links) != 1 { |
| + t.Fatalf("linksOn: %v (%d links)", err, len(links)) |
| + } |
| + if err := app.revokeLink(s.ID, links[0].ID); err != nil { |
| + t.Fatalf("revokeLink: %v", err) |
| + } |
| + if rec := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)); rec.Code != http.StatusNotFound { |
| + t.Fatalf("a revoked link still serves: %d", rec.Code) |
| + } |
| + // And it disappears from the share screen's live list. |
| + live, err := app.linksOn(s.ID) |
| + if err != nil { |
| + t.Fatalf("linksOn: %v", err) |
| + } |
| + if len(live) != 0 { |
| + t.Fatalf("a revoked link is still listed as live") |
| + } |
| +} |
| + |
| +// One answer for every failure, so the route is not an oracle for which |
| +// tokens were ever real. |
| +func TestABadTokenIs404(t *testing.T) { |
| + app, _ := signIn(t) |
| + for _, token := range []string{"nope", "", strings.Repeat("A", 43), "../../etc/passwd"} { |
| + path := "/l/" + url.PathEscape(token) |
| + rec := do(t, app, httptest.NewRequest(http.MethodGet, path, nil)) |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("GET %s = %d, want 404", path, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +// The token is shown once because only its hash is kept. That is a |
| +// property of the design, so it is asserted rather than described. |
| +func TestTheTokenIsShownOnceAndNotAgain(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + form := url.Values{"role": {string(SheetViewer)}} |
| + rec := do(t, app, as(postForm(sharePath(s)+"/link", form), cookies)) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("creating a link = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + loc := rec.Header().Get("Location") |
| + if !strings.Contains(loc, "token=") { |
| + t.Fatalf("Location %q carries no token", loc) |
| + } |
| + |
| + // Following the redirect shows it. |
| + shown := do(t, app, as(httptest.NewRequest(http.MethodGet, loc, nil), cookies)).Body.String() |
| + if !strings.Contains(shown, "/l/") { |
| + t.Fatal("the share screen did not show the new link") |
| + } |
| + |
| + // Loading the screen again, without the token, cannot. |
| + plain := do(t, app, as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), cookies)).Body.String() |
| + if strings.Contains(plain, "/l/") { |
| + t.Fatal("the share screen showed a link URL without being handed the token — " + |
| + "something is storing it") |
| + } |
| + if !strings.Contains(plain, "link") { |
| + t.Error("the share screen does not mention the live link at all") |
| + } |
| +} |
| + |
| +// A token for another sheet must not be echoed onto this sheet's share |
| +// screen just because it is valid. |
| +func TestTheShareScreenOnlyEchoesItsOwnToken(t *testing.T) { |
| + app, cookies := signIn(t) |
| + one, _ := newSheet(t, app, cookies, "One") |
| + two, _ := newSheet(t, app, cookies, "Two") |
| + |
| + token, err := app.createLink(two.ID, SheetViewer, signedInMember(t, app)) |
| + if err != nil { |
| + t.Fatalf("createLink: %v", err) |
| + } |
| + body := do(t, app, as(httptest.NewRequest(http.MethodGet, |
| + sharePath(one)+"?notice=link&token="+token, nil), cookies)).Body.String() |
| + if strings.Contains(body, token) { |
| + t.Fatal("one sheet's share screen echoed another sheet's link token") |
| + } |
| +} |
| + |
| +// Only an owner may mint or revoke a link — it is the same power as |
| +// granting access, in a different shape. |
| +func TestOnlyAnOwnerCanMintALink(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + guestOn(t, app, s.ID, "ada@elsewhere.example", SheetEditor) |
| + guestCookies := signInAs(t, app, "ada@elsewhere.example") |
| + |
| + form := url.Values{"role": {string(SheetViewer)}} |
| + if rec := do(t, app, as(postForm(sharePath(s)+"/link", form), guestCookies)); rec.Code != http.StatusNotFound { |
| + t.Errorf("an editor minted a link: %d", rec.Code) |
| + } |
| + links, err := app.linksOn(s.ID) |
| + if err != nil { |
| + t.Fatalf("linksOn: %v", err) |
| + } |
| + if len(links) != 0 { |
| + t.Fatal("an editor's attempt created a link anyway") |
| + } |
| +} |
diff --git a/internal/sheets/templates/link.html b/internal/sheets/templates/link.html| new file mode 100644 |
| index 0000000..0c941d9 |
| --- /dev/null |
| +++ b/internal/sheets/templates/link.html |
| @@ -0,0 +1,52 @@ |
| +{{define "content"}} |
| +<header rst-page-header> |
| +<div rst-page-header-titles> |
| +<h1>{{.Data.Sheet.Title}}</h1> |
| +<p rst-page-header-sub>Shared with you as a read-only link.</p> |
| +</div> |
| +</header> |
| + |
| +{{if .Data.CanComment}} |
| +<div rst-callout> |
| +<div rst-callout-body> |
| +<p>You can comment on this sheet once you sign in. Reading needs no |
| +account; anything you write is attributed, and that is what an account |
| +is for.</p> |
| +</div> |
| +</div> |
| +{{end}} |
| + |
| +<div style="overflow-x:auto"> |
| +<table data-cell-table> |
| +<caption class="rst-sr-only">{{.Data.Sheet.Title}}, page {{.Data.Page}} of {{.Data.Pages}}</caption> |
| +<thead> |
| +<tr> |
| +<th scope="col"><span class="rst-sr-only">Row</span></th> |
| +{{range .Data.Cols}}<th scope="col">{{.}}</th>{{end}} |
| +</tr> |
| +</thead> |
| +<tbody> |
| +{{range .Data.Rows}} |
| +<tr> |
| +<th scope="row">{{.Number}}</th> |
| +{{/* No edit form and no link to one. The handler passes a row and |
| + column that cannot match any cell, so .Editing is false |
| + everywhere — read-only expressed in the data rather than trusted |
| + to this template. */}} |
| +{{range .Cells}}<td{{if .Raw}} title="{{.Raw}}"{{end}}>{{if .Display}}{{.Display}}{{else}} {{end}}</td>{{end}} |
| +</tr> |
| +{{end}} |
| +</tbody> |
| +</table> |
| +</div> |
| + |
| +<nav rst-pagination aria-label="Rows"> |
| +{{if .Data.HasPrev}} |
| +<a href="/l/{{.Data.Token}}?page={{.Data.PrevPage}}">Previous rows</a> |
| +{{else}}<span rst-pagination-disabled>Previous rows</span>{{end}} |
| +<span>Page {{.Data.Page}} of {{.Data.Pages}}</span> |
| +{{if .Data.HasNext}} |
| +<a href="/l/{{.Data.Token}}?page={{.Data.NextPage}}">Next rows</a> |
| +{{else}}<span rst-pagination-disabled>Next rows</span>{{end}} |
| +</nav> |
| +{{end}} |
diff --git a/internal/sheets/templates/share.html b/internal/sheets/templates/share.html| index 1074217..396c0b3 100644 |
| --- a/internal/sheets/templates/share.html |
| +++ b/internal/sheets/templates/share.html |
| @@ -40,6 +40,51 @@ They see nothing else about the team, and only the sheets you share.</p> |
| </div> |
| </div> |
| +{{with .Data.NewLink}} |
| +<div rst-callout="warn"> |
| +<div rst-callout-body> |
| +<p><strong>Your link.</strong> Copy it now: only its fingerprint is |
| +stored, so this page cannot show it to you again.</p> |
| +<p><code>{{.}}</code></p> |
| +</div> |
| +</div> |
| +{{end}} |
| + |
| +<h2>Link sharing</h2> |
| +<p rst-help>Off unless you make one. Anybody holding the link can read |
| +this sheet without an account — so it is exactly as private as the |
| +people you send it to. A link can never edit.</p> |
| + |
| +<form rst-form method="post" action="/sheets/{{.Data.Sheet.ID}}/share/link"> |
| +<div rst-field-row> |
| +<div rst-field> |
| +<label rst-field-label for="link-role">A link that can</label> |
| +<select rst-input id="link-role" name="role"> |
| +{{range .Data.LinkRoles}}<option value="{{.}}">{{.}}</option>{{end}} |
| +</select> |
| +</div> |
| +<button rst-btn="primary" type="submit">Create a link</button> |
| +</div> |
| +</form> |
| + |
| +{{if .Data.Links}} |
| +<ul rst-list> |
| +{{$sheet := .Data.Sheet}} |
| +{{range .Data.Links}} |
| +<li rst-lrow> |
| +<div rst-row-main> |
| +<span>A {{.Role}} link</span> |
| +<span rst-row-sub>Made {{.Created.Format "2 Jan 2006, 15:04"}}</span> |
| +</div> |
| +<form method="post" action="/sheets/{{$sheet.ID}}/share/link/revoke"> |
| +<input type="hidden" name="link" value="{{.ID}}"> |
| +<button rst-btn="ghost" class="rst-danger" type="submit">Turn off</button> |
| +</form> |
| +</li> |
| +{{end}} |
| +</ul> |
| +{{end}} |
| + |
| <h2>Who has access</h2> |
| {{if .Data.People}} |
| <ul rst-list> |