The branch review's findings: a guest kind, no editor for readers, hashed tokens
Fable's whole-branch review refused this branch and was right to. Two blockers and ten smaller findings; all of the blockers and all but one of the rest are fixed here. Five mutations run against the new guards and all five are caught. **A reader was handed a live editor (blocker).** `show` used GetFor — correct, a reader may read — and then rendered the owner's page verbatim: ProseMirror mounted, the toolbar enabled, the document went contenteditable. The server refused every write, which was never the point: by the time the 404 arrives the person has typed a paragraph, and save-queue.mjs classifies a 4xx as permanent, drops the batch, and keeps taking keystrokes. They lose the work, having been shown an editor. "It fails safely on the server" is not a defence when the cost is somebody's writing. The editor is now absent rather than disabled — a disabled editor is one CSS rule from being an editor again. **A grant made a full member (blocker).** Suite §1 ruling 1 and Docs §8.2 both say an outside guest is a *guest* member. There was no guest kind, so one reader grant was an unbounded transitive key to the front door: Bob is admitted by Alice's grant, creates documents, grants Mallory, who repeats it — and $DOCS_ALLOWED_ADDRESSES had quietly stopped being the membership list the operator configured. A guest now reads and writes what they were granted and cannot create or share. **Revocation ended nothing.** allows() ran only at sign-in, so revoking a guest's last grant left them a valid session for thirty days. Admission is re-asked per request now. Members pay nothing for it — allows() answers from the configured set before touching the database — and a guest is exactly who the answer can have changed for. **The token is hashed (suite ruling 16).** IsPublished returns a bool rather than a link, because the store genuinely cannot produce a URL any more; that is the ruling expressed as a signature rather than as a convention somebody has to keep. The URL reaches exactly one render through a 30-second one-shot, so the 303 survives without a reload re-posting and rotating the link mid-copy. **Three of my own tests were proving nothing, and finding them is most of what this commit is.** TestSignIn read the last mail sent to anyone. A refused address gets the same 303 ?sent=1 a real one does — deliberately, so the endpoint is not a membership oracle — so it returned somebody else's link and reported success for an address the allowlist had refused. I added a recipient check and it was STILL wrong: a second refusal of an address that once succeeded passes, because the recorder keeps the earlier mail. It now requires a new send. NewForTest wires no BaseCatalog, so catalog strings render as their keys. An assertion I had just written, Contains(body, "published"), passed on the key "docs.share.public.published" no matter what the page said. And the test written to catch the reader-editor bug first banned the bare string "docs-editor", which appears a dozen times in the page's own CSS. It would have failed on a correct page while telling me nothing about the element. Also: created_by restored to grants (the spec's §4 model has it; it was dropped while the nullable doc_id beside it was argued at length), no-store and X-Robots-Tag on /p/, real <title> blocks on the two pages rendering "Hello", address validation and a refusal to grant to yourself, HasGrant scoped so a grant on a deleted document stops holding the door open, a "Shared with you" row marker, no sample library for guests, and the dozen comments still claiming "owner-scoped" about reader-scoped code. Not done, deliberately: caching the CRDT replay behind /p/. The review marked it non-blocking; it is a performance change to a hot path and belongs in its own reviewed branch, not in the tail of this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RVcEQ8NgrPCpRTb6JtBz1Q
25 files changed,
+1257
−113
internal/docs/app.go+21 −5internal/docs/auth.go+79 −5internal/docs/grants.go+89 −11internal/docs/grants_edges_test.go+64 −0internal/docs/guest_test.go+128 −0internal/docs/handlers.go+109 −20internal/docs/links.go+52 −26internal/docs/migrations/0005_grants.sql+11 −0internal/docs/migrations/0006_links.sql+25 −7internal/docs/oneshot.go+84 −0internal/docs/publiclink_test.go+143 −9internal/docs/publish.go+28 −6internal/docs/reader_noeditor_test.go+81 −0internal/docs/render.go+19 −1internal/docs/search.go+1 −1internal/docs/share.go+53 −9internal/docs/shared_list_test.go+40 −0internal/docs/store.go+19 −2internal/docs/stream.go+1 −1internal/docs/testing.go+123 −5internal/docs/views/list.html+2 −2internal/docs/views/public.html+1 −0internal/docs/views/share.html+14 −0internal/docs/views/show.html+9 −3internal/docs/writer_http_test.go+61 −0
CI passed — run details
Intent
diff --git a/internal/docs/app.go b/internal/docs/app.go| index b74468e..664648a 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -18,6 +18,7 @@ import ( |
| "net/http" |
| "net/url" |
| "strings" |
| + "time" |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -385,8 +386,19 @@ func New(cfg Config) (*App, error) { |
| // exists — deliberately, so a misconfigured allowlist refuses to boot |
| // before anything else is built. See allowlist.granted for why an |
| // unredeemable invitation is the failure worth avoiding. |
| + // The sample library is for members. A guest admitted by a grant is |
| + // visiting, not moving in. |
| + store.seedOnlyMembers = list.configured |
| + |
| list.granted = func(address string) bool { |
| - return store.HasGrant(context.Background(), address) |
| + // A short deadline rather than context.Background(): this runs on |
| + // the sign-in path and now on every request (requireAdmission), so |
| + // a stalled query must fail rather than hold a request open. A |
| + // failure answers false, which refuses admission — the safe |
| + // direction, and the configured allowlist still admits members. |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + return store.HasGrant(ctx, address) |
| } |
| render, err := newRenderer() |
| @@ -410,7 +422,7 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: build auth: %w", err) |
| } |
| - h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin} |
| + h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin, isMember: list.configured, oneShots: newOneShots()} |
| r := chi.NewRouter() |
| // Mounted above every route, per rastrillo SKILL.md §5: refuses a |
| @@ -456,10 +468,14 @@ func New(cfg Config) (*App, error) { |
| // GET/HEAD redirects to /signin; anything else 403s. |
| r.Group(func(r chi.Router) { |
| r.Use(authPlugin.RequireSession) |
| + // Admission is re-asked per request, not only at sign-in, so |
| + // revoking a guest's last grant ends their access now rather |
| + // than whenever their 30-day session happens to expire. |
| + r.Use(requireAdmission(list, "/signin")) |
| r.Get("/", h.list) |
| r.Post("/documents", h.create) |
| // Title search for the jump-to palette (search.go). Inside |
| - // this group like every other document route, and owner-scoped |
| + // this group like every other document route, and reader-scoped |
| // through Store.Search → Store.List rather than through a |
| // check of its own — a person's document titles are theirs. |
| r.Get("/documents/search", h.searchJSON) |
| @@ -482,7 +498,7 @@ func New(cfg Config) (*App, error) { |
| r.Post("/d/{sqid}/updates", h.updatesPost) |
| // Export (export.go). Inside this group, so a signed-out |
| // request gets the same redirect every other document route |
| - // gives it, and owner-scoped through the same Store.Get, so a |
| + // gives it, and reader-scoped through the same Store.GetFor, so a |
| // document someone else owns 404s identically to one that does |
| // not exist. Extensions in the path rather than a ?format= |
| // query: the URL is what a person sees when they save the |
| @@ -491,7 +507,7 @@ func New(cfg Config) (*App, error) { |
| r.Get("/d/{sqid}/export.md", h.exportMarkdown) |
| r.Get("/d/{sqid}/export.pdf", h.exportPDF) |
| // Plan 5: the live-update stream (stream.go). A GET, so |
| - // csrf.Protect passes it through untouched, and owner-scoped |
| + // csrf.Protect passes it through untouched, and reader-scoped |
| // through the same getDocumentForUpdates helper as the two |
| // routes above — a stranger gets 404, never 403. |
| r.Get("/d/{sqid}/stream", h.stream) |
diff --git a/internal/docs/auth.go b/internal/docs/auth.go| index 0c5c744..254660b 100644 |
| --- a/internal/docs/auth.go |
| +++ b/internal/docs/auth.go |
| @@ -107,14 +107,29 @@ func buildMailer(cfg Config, logger *slog.Logger) (mail.Sender, *recordingMailer |
| // dev-mode behavior (the link lands in the log, clearly marked) is |
| // unchanged; this only adds a second, in-memory place to find it. |
| type recordingMailer struct { |
| - mu sync.Mutex |
| - next mail.Sender |
| + mu sync.Mutex |
| + next mail.Sender |
| + // lastTo is recorded alongside lastBody, and it is not decoration. |
| + // gateBegin answers a refused address with exactly the 303 ?sent=1 a |
| + // real one gets — deliberately, so the endpoint is not a membership |
| + // oracle — so a caller that reads only lastBody cannot tell "a link |
| + // was mailed to this address" from "somebody else's link is still |
| + // sitting here". TestSignIn used to make that mistake and reported |
| + // success for an address the allowlist had refused. |
| + lastTo string |
| lastBody string |
| + // sends counts mails, and it is the field that makes the recipient |
| + // check above actually work. Comparing only lastTo answers "was the |
| + // most recent mail addressed here", which stays true for every later |
| + // REFUSAL of the same address — the recorder simply keeps the earlier |
| + // success. A caller has to know a NEW mail was sent, not that an old |
| + // one is still lying there. |
| + sends int |
| } |
| func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error { |
| m.mu.Lock() |
| - m.lastBody = body |
| + m.lastTo, m.lastBody, m.sends = to, body, m.sends+1 |
| m.mu.Unlock() |
| return m.next.Send(ctx, to, subject, body) |
| } |
| @@ -168,6 +183,32 @@ type allowlist struct { |
| granted func(address string) bool |
| } |
| +// configured reports whether address is a MEMBER of this instance — on |
| +// $DOCS_ALLOWED_ADDRESSES, rather than admitted by a grant. |
| +// |
| +// The distinction the branch review found missing (finding 1). Suite §1 |
| +// ruling 1 and Docs §8.2 both say an outside guest is a *guest* member; |
| +// the branch had no guest kind, so a grant was a full membership and |
| +// admission became transitive — Alice grants Bob, Bob creates and grants |
| +// Mallory, and the operator's allowlist has quietly stopped being the |
| +// membership list. |
| +// |
| +// A guest reads and writes what they were granted. Creating documents and |
| +// changing who reaches one are member powers, and this is the predicate |
| +// that says so. |
| +// |
| +// addrs == nil (development's admit-everyone) makes everybody a member, |
| +// which is the same latitude allows() already gives and the same reason: |
| +// a dev instance with no configuration should not need a membership list |
| +// to be usable. |
| +func (a allowlist) configured(address string) bool { |
| + if a.addrs == nil { |
| + return true |
| + } |
| + _, ok := a.addrs[normalizeAddress(address)] |
| + return ok |
| +} |
| + |
| // allows reports whether address may sign in. |
| func (a allowlist) allows(address string) bool { |
| if a.addrs == nil { |
| @@ -177,8 +218,10 @@ func (a allowlist) allows(address string) bool { |
| if _, ok := a.addrs[normalized]; ok { |
| return true |
| } |
| - // A grant admits its holder, and nobody else. This only ever widens: an |
| - // addrs == nil allowlist has already returned true above. |
| + // A grant admits its holder, and nobody else — as a guest, not as a |
| + // member: see configured() above for the powers that stay behind the |
| + // configured list, and requireAdmission for why this is now re-asked on |
| + // every request rather than only at sign-in. |
| return a.granted != nil && a.granted(normalized) |
| } |
| @@ -303,6 +346,37 @@ func gateBegin(next http.HandlerFunc, list allowlist, signinPath string) http.Ha |
| } |
| } |
| +// requireAdmission re-asks the front-door question on every request, and |
| +// answers a person who no longer passes it by ending them at /signin. |
| +// |
| +// It exists because allows() used to run only at sign-in. A session lasts |
| +// 30 days (rastrillo/auth.DefaultSessionTTL), so revoking somebody's last |
| +// grant took their document away and left them a working session for a |
| +// month — during which, before the guest rule above, they went on creating |
| +// documents and granting them to other people. "Stop sharing" was complete |
| +// for the document and silent about the instance. (Branch review, finding |
| +// 3.) |
| +// |
| +// Cheap for members: allows() checks the configured set first and returns |
| +// before touching the database. Only a guest pays a query, and a guest is |
| +// exactly who the answer can have changed for. |
| +// |
| +// It sits after RequireSession so it can rely on there being an identity; |
| +// with no identity it does nothing and lets RequireSession's own redirect |
| +// stand. |
| +func requireAdmission(list allowlist, signinPath string) func(http.Handler) http.Handler { |
| + return func(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + id, ok := rauth.From(r) |
| + if ok && !list.allows(id.Address) { |
| + http.Redirect(w, r, signinPath, http.StatusSeeOther) |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| + } |
| +} |
| + |
| // newAuthPlugin wires rastrillo/auth per its SKILL.md contract: one |
| // *Auth built at boot (New — SKILL.md's own warning against building |
| // a Flow per request, since the rate limiter's budget lives on the |
diff --git a/internal/docs/grants.go b/internal/docs/grants.go| index e663637..8723c45 100644 |
| --- a/internal/docs/grants.go |
| +++ b/internal/docs/grants.go |
| @@ -31,10 +31,15 @@ func (r Role) valid() bool { return r == RoleReader || r == RoleWriter } |
| // sample library, so minting a Person row to hold a grant would hand an |
| // invitee six documents they never asked for before they had accepted. |
| type Grant struct { |
| - ID int64 `gorm:"column:id;primaryKey"` |
| - DocID int64 `gorm:"column:doc_id;index"` |
| - Subject string `gorm:"column:subject;index"` |
| - Role Role `gorm:"column:role"` |
| + ID int64 `gorm:"column:id;primaryKey"` |
| + DocID int64 `gorm:"column:doc_id;index"` |
| + Subject string `gorm:"column:subject;index"` |
| + Role Role `gorm:"column:role"` |
| + // CreatedBy is the person who made this grant — the spec's §4 data |
| + // model has it and this table shipped without it. It is what lets an |
| + // operator answer "who let this address in", which is a question with |
| + // no other source once a grant exists. |
| + CreatedBy int64 `gorm:"column:created_by"` |
| CreatedAt time.Time `gorm:"column:created_at"` |
| } |
| @@ -84,8 +89,37 @@ func writable(g *gorm.DB, a Actor) *gorm.DB { |
| // subjectOf normalises an address the way EnsurePerson stores one, so a grant |
| // made to "Bob@Example.com " is the grant Bob holds when he signs in. |
| +// |
| +// It defers to normalizeAddress rather than repeating it. The two were the |
| +// same two lines in two files — the two halves of one key, free to drift |
| +// into comparing differently-normalised forms of the same address, which |
| +// would have shown up as a grant that silently admits nobody. |
| func subjectOf(address string) string { |
| - return strings.ToLower(strings.TrimSpace(address)) |
| + return normalizeAddress(address) |
| +} |
| + |
| +// addressOf maps a person id back to the address they signed in with, so |
| +// Grant can refuse a grant somebody makes to themselves. |
| +func (s *Store) addressOf(ctx context.Context, person int64) (string, error) { |
| + var p Person |
| + if err := s.db.WithContext(ctx).First(&p, person).Error; err != nil { |
| + return "", err |
| + } |
| + return subjectOf(p.Email), nil |
| +} |
| + |
| +// looksLikeAnAddress is the cheapest check that keeps nonsense out of the |
| +// front door. |
| +// |
| +// Not a validator: RFC 5322 is not worth implementing here, and rauth owns |
| +// the real parsing downstream. This exists because Grant accepted any |
| +// non-empty string, so "not-an-email" became a row, appeared on the share |
| +// page, and made HasGrant answer true — widening admission to a string |
| +// that can never sign in, and leaving the owner a share list with a typo |
| +// on it they will read as a person. (Branch review, finding 10.) |
| +func looksLikeAnAddress(subject string) bool { |
| + at := strings.Index(subject, "@") |
| + return at > 0 && at < len(subject)-1 && !strings.ContainsAny(subject, " \t") |
| } |
| // readable scopes a query to the documents one person may read: the ones they |
| @@ -128,7 +162,7 @@ func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, ro |
| return fmt.Errorf("docs: unknown role %q", role) |
| } |
| subject := subjectOf(address) |
| - if subject == "" { |
| + if subject == "" || !looksLikeAnAddress(subject) { |
| return errEmptyAddress |
| } |
| @@ -140,10 +174,23 @@ func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, ro |
| return err |
| } |
| - grant := Grant{DocID: doc.ID, Subject: subject, Role: role, CreatedAt: time.Now().UTC()} |
| + // Granting to yourself is refused: you already reach it by owning it, |
| + // and the row would put the owner on their own share list as though |
| + // somebody had been invited. |
| + if ownerAddress, err := s.addressOf(ctx, owner); err == nil && ownerAddress == subject { |
| + return errEmptyAddress |
| + } |
| + |
| + grant := Grant{ |
| + DocID: doc.ID, |
| + Subject: subject, |
| + Role: role, |
| + CreatedBy: owner, |
| + CreatedAt: time.Now().UTC(), |
| + } |
| return s.db.WithContext(ctx).Clauses(clause.OnConflict{ |
| Columns: []clause.Column{{Name: "doc_id"}, {Name: "subject"}}, |
| - DoUpdates: clause.AssignmentColumns([]string{"role"}), |
| + DoUpdates: clause.AssignmentColumns([]string{"role", "created_by"}), |
| }).Create(&grant).Error |
| } |
| @@ -161,9 +208,18 @@ func (s *Store) HasGrant(ctx context.Context, address string) bool { |
| if subject == "" { |
| return false |
| } |
| + // Scoped to grants on documents that still exist. A grant on a |
| + // soft-deleted or orphaned (owner_id = 0) document opens nothing — |
| + // readable hides those rows — so counting it here would hold the front |
| + // door open for somebody who can reach nothing at all, and would keep |
| + // holding it after every document they were given had been deleted. |
| + // (Branch review, finding 12.) |
| var count int64 |
| if err := s.db.WithContext(ctx).Model(&Grant{}). |
| - Where("subject = ?", subject).Count(&count).Error; err != nil { |
| + Joins("JOIN documents ON documents.id = grants.doc_id"). |
| + Where("grants.subject = ? AND documents.deleted_at IS NULL AND documents.owner_id > 0", |
| + subject). |
| + Count(&count).Error; err != nil { |
| s.logger.Error("docs: checking grants for admission", "err", err) |
| return false |
| } |
| @@ -224,6 +280,28 @@ func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, er |
| return &doc, nil |
| } |
| +// CanWrite reports whether the acting person may change a document. |
| +// |
| +// The same EXISTS clause writable builds, asked as a question rather than |
| +// used as a scope, because the document page needs the answer before it |
| +// renders: a reader must not be handed an editor (branch review, #2). The |
| +// one predicate, asked twice, so the page and the update route cannot |
| +// disagree about who may write. |
| +// |
| +// A database error answers false. Rendering a read-only page to somebody |
| +// who may write costs them a reload; rendering an editor to somebody who |
| +// may not costs them their work. |
| +func (s *Store) CanWrite(ctx context.Context, a Actor, docID int64) bool { |
| + var count int64 |
| + if err := writable(s.db.WithContext(ctx).Model(&Document{}), a). |
| + Where("documents.id = ?", docID). |
| + Count(&count).Error; err != nil { |
| + s.logger.Error("docs: checking write access", "doc", docID, "err", err) |
| + return false |
| + } |
| + return count > 0 |
| +} |
| + |
| // ListFor returns every non-deleted document the acting person may read — |
| // the ones they own, plus the ones they hold a grant on — newest first, on |
| // the same UpdatedAt clock List uses so this list and handlers.go's recency |
| @@ -236,8 +314,8 @@ func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, er |
| // The order deliberately does not separate "mine" from "shared with me". |
| // Recency is the sort the list page already teaches, and a shared document |
| // somebody is working in today belongs above one of yours you have not |
| -// opened since spring. Which are yours is a question the row can answer |
| -// without reordering the list. |
| +// opened since spring. Which are yours the row answers itself, through |
| +// docRow.SharedNote, without reordering the list. |
| func (s *Store) ListFor(ctx context.Context, a Actor) ([]*Document, error) { |
| var docs []*Document |
| err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject). |
diff --git a/internal/docs/grants_edges_test.go b/internal/docs/grants_edges_test.go| new file mode 100644 |
| index 0000000..2417ed2 |
| --- /dev/null |
| +++ b/internal/docs/grants_edges_test.go |
| @@ -0,0 +1,64 @@ |
| +package docs_test |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// Grant refuses what is not an address. |
| +// |
| +// Before this, Grant accepted any non-empty string: "not-an-email" became |
| +// a row, appeared on the share page as though it were a person, and made |
| +// HasGrant answer true — widening the instance's front door to a string |
| +// that can never sign in. (Branch review, finding 10.) |
| +func TestGrantRefusesNonsenseAddresses(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + |
| + for _, bad := range []string{"not-an-email", "@example.com", "bob@", "bob example@x.com"} { |
| + rec := post(t, app, alice, "/d/"+sqid+"/share", "address="+strings.ReplaceAll(bad, " ", "+")) |
| + if rec.Code == http.StatusSeeOther { |
| + t.Errorf("granted to %q", bad) |
| + } |
| + if body := get(t, app, alice, "/d/"+sqid+"/share").Body.String(); strings.Contains(body, bad) { |
| + t.Errorf("the share page lists %q", bad) |
| + } |
| + } |
| +} |
| + |
| +// An owner cannot grant to themselves. They already reach it by owning it, |
| +// and the row would put them on their own share list looking like an |
| +// invitation somebody made. |
| +func TestGrantRefusesTheOwner(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + |
| + if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=alice@example.com&role=writer"); rec.Code == http.StatusSeeOther { |
| + t.Error("the owner granted a document to herself") |
| + } |
| +} |
| + |
| +// A grant on a document that no longer exists does not hold the front door |
| +// open. Admission is meant to follow from reaching something; a grant on a |
| +// deleted document reaches nothing, because readable hides it. |
| +func TestAGrantOnADeletedDocumentAdmitsNobody(t *testing.T) { |
| + app := members(t, "alice@example.com") |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader") |
| + |
| + if _, err := app.TestSignIn(t, "bob@example.com"); err != nil { |
| + t.Fatalf("precondition: the grantee cannot sign in: %v", err) |
| + } |
| + |
| + app.TestSoftDelete(t, sqid) |
| + |
| + if _, err := app.TestSignIn(t, "bob@example.com"); err == nil { |
| + t.Error("a grant on a deleted document still admits its holder") |
| + } |
| +} |
diff --git a/internal/docs/guest_test.go b/internal/docs/guest_test.go| new file mode 100644 |
| index 0000000..dfa1a1b |
| --- /dev/null |
| +++ b/internal/docs/guest_test.go |
| @@ -0,0 +1,128 @@ |
| +package docs_test |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// members builds an app whose front door is a real allowlist, so the |
| +// difference between a configured member and somebody admitted only by a |
| +// grant actually exists. NewForTest's empty allowlist means "admit |
| +// everyone" in development, which is why none of this was reachable before. |
| +func members(t *testing.T, addresses string) *docs.App { |
| + t.Helper() |
| + app, err := docs.NewForTestWith(t, docs.Config{AllowedAddresses: addresses}) |
| + if err != nil { |
| + t.Fatalf("NewForTestWith: %v", err) |
| + } |
| + return app |
| +} |
| + |
| +// A grant admits its holder as a GUEST, not as a member. |
| +// |
| +// Suite §1 ruling 1: "Sharing is membership; an outside guest is a guest |
| +// member." Docs §8.2: "Outside guests are granted a specific document and |
| +// become guest members." The branch had no guest kind at all, so a single |
| +// reader grant was an unbounded, transitive key to the instance's front |
| +// door: Bob is admitted by Alice's grant, creates his own documents, shares |
| +// them to Mallory, who is now admitted and can repeat it. Alice cannot |
| +// undo any of it, and $DOCS_ALLOWED_ADDRESSES silently stopped being the |
| +// membership list the operator configured. (Branch review, finding 1.) |
| +// |
| +// A guest reads and writes what they were granted, and nothing else. |
| +func TestAGuestCannotCreateOrShare(t *testing.T) { |
| + app := members(t, "alice@example.com") |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer") |
| + |
| + // Bob gets in — the invitation is redeemable, which is the whole |
| + // reason grant-admission exists. |
| + bob := signInCookies(t, app, "bob@example.com") |
| + if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK { |
| + t.Fatalf("the guest cannot read the document he was granted: %d", rec.Code) |
| + } |
| + |
| + // But he is a guest. He cannot mint documents of his own... |
| + if rec := post(t, app, bob, "/documents", "title=Bob%27s+own"); rec.Code == http.StatusSeeOther { |
| + t.Error("a guest created a document; a grant is not a membership") |
| + } |
| + // ...and he cannot admit anybody else, which is the transitive half |
| + // and the one that makes the first unbounded. |
| + if rec := post(t, app, bob, "/d/"+sqid+"/share", "address=mallory@example.com&role=writer"); rec.Code == http.StatusSeeOther { |
| + t.Error("a guest shared a document; admission would be transitive") |
| + } |
| +} |
| + |
| +// A configured member keeps everything. Without this the rule above passes |
| +// on an app where nobody can create anything. |
| +func TestAMemberStillCreatesAndShares(t *testing.T) { |
| + app := members(t, "alice@example.com,carol@example.com") |
| + carol := signInCookies(t, app, "carol@example.com") |
| + |
| + sqid := createDoc(t, app, carol, "Carol's") |
| + if rec := post(t, app, carol, "/d/"+sqid+"/share", "address=dave@example.com&role=reader"); rec.Code != http.StatusSeeOther { |
| + t.Errorf("a configured member could not share: status %d", rec.Code) |
| + } |
| +} |
| + |
| +// Revoking the last grant ends admission, not just the document. |
| +// |
| +// Before this, `allows()` ran only at sign-in, so a revoked guest kept a |
| +// valid session for up to 30 days — and, per the finding above, kept |
| +// creating and sharing with it. "Stop sharing" was complete for the |
| +// document and said nothing about the instance. (Branch review, finding 3.) |
| +func TestRevokingTheLastGrantEndsAdmission(t *testing.T) { |
| + app := members(t, "alice@example.com") |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader") |
| + |
| + bob := signInCookies(t, app, "bob@example.com") |
| + if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK { |
| + t.Fatalf("precondition: the guest cannot read (%d)", rec.Code) |
| + } |
| + |
| + post(t, app, alice, "/d/"+sqid+"/share/revoke", "address=bob@example.com") |
| + |
| + // The same cookies, still cryptographically valid, now reach nothing. |
| + for _, path := range []string{"/", "/d/" + sqid} { |
| + if rec := get(t, app, bob, path); rec.Code == http.StatusOK { |
| + t.Errorf("a revoked guest still reads %s with a live session", path) |
| + } |
| + } |
| + |
| + // A member is not touched by any of this: the recheck must key on |
| + // admission, not on holding a grant. |
| + if rec := get(t, app, alice, "/"); rec.Code != http.StatusOK { |
| + t.Errorf("the owner lost access to her own list: %d", rec.Code) |
| + } |
| +} |
| + |
| +// The wiring, end to end through the real sign-in, rather than allows() |
| +// and HasGrant each tested against a stand-in for the other. |
| +func TestAGrantAdmitsThroughTheRealSignIn(t *testing.T) { |
| + app := members(t, "alice@example.com") |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + |
| + // Before any grant, Bob is refused — and refused invisibly, since |
| + // gateBegin answers a refused address exactly as it answers a real |
| + // one so the endpoint is not a membership oracle. |
| + if _, err := app.TestSignIn(t, "bob@example.com"); err == nil { |
| + t.Fatal("an address on no list and holding no grant was mailed a link") |
| + } |
| + |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader") |
| + |
| + link, err := app.TestSignIn(t, "bob@example.com") |
| + if err != nil { |
| + t.Fatalf("a granted address was not mailed a link: %v", err) |
| + } |
| + if !strings.Contains(link, "/auth/verify") { |
| + t.Fatalf("not a verify link: %q", link) |
| + } |
| +} |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index b0d4285..67a7cd9 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -58,6 +58,23 @@ type handlers struct { |
| streamHeartbeat time.Duration |
| streamTTL time.Duration |
| + // isMember answers "is this address on the configured allowlist", |
| + // as opposed to admitted by a grant. It gates the member-only powers |
| + // — creating a document, and changing who reaches one — so that a |
| + // guest reads and writes what they were granted and nothing else |
| + // (suite §1 ruling 1, Docs §8.2, branch review finding 1). |
| + // |
| + // A func rather than the allowlist value because handlers has no |
| + // business with the rest of that type, and because app.go must be the |
| + // one place the two halves of admission are wired together. |
| + isMember func(address string) bool |
| + |
| + // oneShots parks a freshly minted public URL for exactly one render |
| + // (oneshot.go). Here rather than on Store for the reason hub and |
| + // previews are: Store is persistence, and this value is deliberately |
| + // never persisted. |
| + oneShots *oneShots |
| + |
| // origin is cfg.Origin, the app's own validated base URL (app.go's |
| // New refuses to boot without one outside development). The share |
| // page needs it to print a public link somebody can copy into a |
| @@ -147,6 +164,16 @@ type docRow struct { |
| // could not be read, or one past a cold render's budget — and |
| // renders as the empty box the row has always shown. |
| Thumb thumbView |
| + |
| + // SharedNote is set on a row the reader does not own — "Shared with |
| + // you" — and empty on their own. |
| + // |
| + // The list used to be able to say nothing about whose a document was, |
| + // because every row was the reader's; the comment on `list` said so |
| + // and gave that as the reason. Sharing made it false, and a row that |
| + // looks like yours and is not is the setup for opening somebody's |
| + // document expecting to edit it. (Branch review, finding 9.) |
| + SharedNote string |
| } |
| // owner resolves the signed-in person's Store.Person id for r — |
| @@ -185,6 +212,23 @@ func (h *handlers) actor(r *http.Request) (Actor, error) { |
| return Actor{PersonID: person, Subject: id.Address}, nil |
| } |
| +// member reports whether the person on this request is a full member of |
| +// the instance rather than a guest admitted by a grant. |
| +// |
| +// A guest reaching a member-only route is answered 403 rather than 404. |
| +// The app answers 404 for documents, and for a good reason (§4.2: "not |
| +// yours" must not be distinguishable from "does not exist"), but there is |
| +// no document here to hide and nothing secret about the instance having |
| +// members — the guest was invited by one. Pretending POST /documents does |
| +// not exist would be a lie told for no gain. |
| +func (h *handlers) member(r *http.Request) bool { |
| + if h.isMember == nil { |
| + return true |
| + } |
| + id, ok := rauth.From(r) |
| + return ok && h.isMember(id.Address) |
| +} |
| + |
| // list serves GET /: the document list, spec §6.4 and Paul's four |
| // rulings against it — newest first, grouped by recency, one row per |
| // document. Cut, deliberately, not by oversight: no template |
| @@ -252,10 +296,11 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| thumb := h.thumbnailer(r.Context()) |
| for _, d := range hits { |
| data.Results = append(data.Results, docRow{ |
| - Href: "/d/" + d.Sqid, |
| - Title: d.Title, |
| - When: formatWhen(now, d.UpdatedAt), |
| - Thumb: thumb(d), |
| + SharedNote: sharedNote(r, d, who.PersonID), |
| + Href: "/d/" + d.Sqid, |
| + Title: d.Title, |
| + When: formatWhen(now, d.UpdatedAt), |
| + Thumb: thumb(d), |
| }) |
| } |
| if err := h.render.page(w, r, "list", data); err != nil { |
| @@ -271,7 +316,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| return |
| } |
| - data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context())) |
| + data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context()), who.PersonID) |
| if err := h.render.page(w, r, "list", data); err != nil { |
| h.logger.Error("docs: render list", "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| @@ -288,6 +333,14 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| // navigation, replays the GET the redirect points at, never the POST |
| // that created the document. |
| func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| + // Member-only: a guest reads and writes what they were granted, and |
| + // minting documents of their own is not that. Without this, one grant |
| + // is a transitive key to the front door — the guest creates, shares, |
| + // and admits the next person (branch review, finding 1). |
| + if !h.member(r) { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + return |
| + } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve signed-in person", "err", err) |
| @@ -368,8 +421,27 @@ type showPage struct { |
| // as nothing at all — so {{icon "share"}} would have shipped an |
| // invisible control that no test of ours would have caught. A |
| // thirteenth icon is a rastrillo change, not a docs one. |
| - CanShare bool |
| - ShareLabel string |
| + CanShare bool |
| + ShareLabel string |
| + |
| + // CanWrite gates the editor: the ProseMirror mount point, its |
| + // data-sqid, and the script that mounts it (views/show.html). |
| + // |
| + // A reader grantee reaches this page — GetFor admits them, and that is |
| + // correct, they may read it. What was not correct was rendering them |
| + // the owner's page: the bundle mounted, the toolbar enabled, and the |
| + // document became contenteditable for somebody every write of whose |
| + // the server refuses. The refusal worked; that is not the point. By |
| + // the time the 404 arrives the person has typed a paragraph, and |
| + // save-queue.mjs classifies a 4xx as permanent, drops the batch and |
| + // keeps taking keystrokes. They lose the work, having been shown an |
| + // editor. "It fails safely on the server" is not a defence when the |
| + // cost is a person's work. (Branch review, finding 2.) |
| + // |
| + // So the editor is absent rather than disabled. A disabled editor is |
| + // one CSS rule from being an editor again; an absent one has nothing |
| + // to re-enable. views/public.html already demonstrates the shape. |
| + CanWrite bool |
| EditorScript string // views/show.html's <script src>, from editorScriptPath() — never a literal, so it can never drift from what staticHandler actually serves |
| // SaveRetrying/SaveFailed are the editor's own save-status copy |
| @@ -479,7 +551,7 @@ func newDialogView(r *http.Request) dialogView { |
| // not plain text — inside a page-shaped canvas (views/show.html). An |
| // unknown sqid and a sqid someone else owns 404 identically — spec |
| // §4.2's contract that a document you cannot see is indistinguishable |
| -// from one that does not exist — because Store.Get's owner-scoped |
| +// from one that does not exist — because Store.GetFor's reader-scoped |
| // query never distinguishes "no such row" from "not yours" in the |
| // first place; there is no separate check here that could 403 by |
| // mistake. |
| @@ -520,6 +592,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| BackLabel: rastrillo.T(r, "docs.list.title"), |
| Sqid: doc.Sqid, |
| CanShare: doc.OwnerID == actor.PersonID, |
| + CanWrite: h.store.CanWrite(r.Context(), actor, doc.ID), |
| ShareLabel: rastrillo.T(r, "docs.share.title"), |
| EditorScript: editorScriptPath(), |
| SaveRetrying: rastrillo.T(r, "docs.editor.status.retrying"), |
| @@ -548,10 +621,10 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| // corrupt document that looks intact. |
| const maxLogBytes = 8 << 20 |
| -// getDocumentForUpdates is the owner-scoped lookup both |
| +// getDocumentForUpdates is the reader-scoped lookup both |
| // updatesGet and updatesPost share: it resolves the signed-in |
| // person, then loads the document through Store.Get — already |
| -// owner-scoped and already 404-not-403 (its own doc comment) — so a |
| +// reader-scoped and already 404-not-403 (its own doc comment) — so a |
| // stranger's sqid returns exactly the same outcome as one that does |
| // not exist. ok is false once a response has already been written |
| // (404 or 500) and the caller must return immediately without doing |
| @@ -590,7 +663,7 @@ func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request) |
| // will not decode; the server cannot detect that corruption itself |
| // (it never calls into the core here), so the browser is the only |
| // reader that can, and raw=1 is what lets it fall back to the one |
| -// source it can always trust. Owner-scoped exactly like show |
| +// source it can always trust. Reader-scoped exactly like show |
| // (getDocumentForUpdates), and capped at maxLogBytes above. |
| // |
| // Why snapshot-then-tail loses nothing: a snapshot IS an ordinary Yjs |
| @@ -747,11 +820,16 @@ func (h *handlers) updatesPost(w http.ResponseWriter, r *http.Request) { |
| seq, err := h.store.ApplyUpdate(r.Context(), doc, actor, update) |
| if err != nil { |
| - // A reader posting an update gets the same 404 a stranger gets. |
| - // Spec §4.2: a document you may not write does not announce itself |
| - // as one you may not write. |
| + // A reader posting an update gets the same 404 a stranger gets — |
| + // the same BODY too, which it did not used to be. This branch |
| + // answered http.NotFound ("404 page not found") while a stranger |
| + // got http.Error with the catalog's text, so the sentence above |
| + // and store.go's "refused identically" were both false. Harmless |
| + // as an oracle (a reader already knows the document exists) and |
| + // still worth fixing, because the comment was the thing being |
| + // relied on. (Branch review, finding 7f.) |
| if errors.Is(err, ErrNotWritable) { |
| - http.NotFound(w, r) |
| + http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound) |
| return |
| } |
| if updateIsClientFault(err) { |
| @@ -833,6 +911,16 @@ func updateIsClientFault(err error) bool { |
| return core.Recovered(err) || core.Rejected(err) || errors.Is(err, core.ErrUpdateLogFull) |
| } |
| +// sharedNote labels a row the reader does not own. Empty for their own |
| +// documents: a list where every row carries a badge is a list where none |
| +// of them reads as information. |
| +func sharedNote(r *http.Request, d *Document, mine int64) string { |
| + if d.OwnerID == mine { |
| + return "" |
| + } |
| + return rastrillo.T(r, "docs.list.shared") |
| +} |
| + |
| // groupByRecency buckets docs — already newest-first, per Store.List |
| // — into recency groups without re-sorting: consecutive documents |
| // that share a bucket join the same group, and a new bucket starts a |
| @@ -849,7 +937,7 @@ func updateIsClientFault(err error) bool { |
| // group labels ("Today … Yesterday … Today") the instant an older |
| // document got a new update. See store_test.go's |
| // TestListOrdersByUpdatedAt. |
| -func groupByRecency(r *http.Request, now time.Time, docs []*Document, thumb func(*Document) thumbView) []docGroup { |
| +func groupByRecency(r *http.Request, now time.Time, docs []*Document, thumb func(*Document) thumbView, mine int64) []docGroup { |
| var groups []docGroup |
| var key string |
| for _, d := range docs { |
| @@ -860,10 +948,11 @@ func groupByRecency(r *http.Request, now time.Time, docs []*Document, thumb func |
| } |
| g := &groups[len(groups)-1] |
| g.Rows = append(g.Rows, docRow{ |
| - Href: "/d/" + d.Sqid, |
| - Title: d.Title, |
| - When: formatWhen(now, d.UpdatedAt), |
| - Thumb: thumb(d), |
| + SharedNote: sharedNote(r, d, mine), |
| + Href: "/d/" + d.Sqid, |
| + Title: d.Title, |
| + When: formatWhen(now, d.UpdatedAt), |
| + Thumb: thumb(d), |
| }) |
| } |
| return groups |
diff --git a/internal/docs/links.go b/internal/docs/links.go| index 3f86f9f..d6ef71f 100644 |
| --- a/internal/docs/links.go |
| +++ b/internal/docs/links.go |
| @@ -3,8 +3,9 @@ package docs |
| import ( |
| "context" |
| "crypto/rand" |
| + "crypto/sha256" |
| "encoding/base64" |
| - "errors" |
| + "encoding/hex" |
| "time" |
| "gorm.io/gorm" |
| @@ -17,9 +18,13 @@ import ( |
| // no Role field is the ruling's other half — every write has a named |
| // actor, and a link holds no name. See 0006_links.sql. |
| type Link struct { |
| - ID int64 `gorm:"column:id;primaryKey"` |
| - DocID int64 `gorm:"column:doc_id;uniqueIndex"` |
| - Token string `gorm:"column:token;uniqueIndex"` |
| + ID int64 `gorm:"column:id;primaryKey"` |
| + DocID int64 `gorm:"column:doc_id;uniqueIndex"` |
| + // TokenHash, never the token (suite doctrine ruling 16). There is no |
| + // field on this struct that can yield a working URL, which is the |
| + // point: nothing that reads a Link out of the database — a query, a |
| + // log line, a debugger — is holding a capability. |
| + TokenHash string `gorm:"column:token_hash;uniqueIndex"` |
| CreatedAt time.Time `gorm:"column:created_at"` |
| } |
| @@ -49,6 +54,24 @@ func newToken() (string, error) { |
| return base64.RawURLEncoding.EncodeToString(buf), nil |
| } |
| +// hashToken is the one-way form stored in the database. |
| +// |
| +// SHA-256, unsalted, and both halves of that are deliberate. A salt |
| +// defends against precomputation over a small input space; there is no |
| +// small input space here, because the token is 128 bits from crypto/rand |
| +// and is its own salt. A password hash (bcrypt, argon2) would buy the same |
| +// nothing while making every load of a published page do key-stretching |
| +// work. The property that matters — the stored form does not yield the |
| +// token — a fast hash gives just as completely. |
| +// |
| +// Hex rather than base64 for the stored form: it is fixed-width, it is |
| +// what a person comparing rows by eye expects of a SHA-256, and the column |
| +// is never parsed by anything but this function. |
| +func hashToken(token string) string { |
| + sum := sha256.Sum256([]byte(token)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| // Publish turns a document's public link on and returns it, minting a |
| // fresh token every time. |
| // |
| @@ -60,17 +83,17 @@ func newToken() (string, error) { |
| // Owner-only through Get, so a writer grantee is answered exactly as a |
| // stranger is — no grant carries re-sharing, and publishing to everyone is |
| // the largest re-share there is. |
| -func (s *Store) Publish(ctx context.Context, owner int64, sqid string) (*Link, error) { |
| +func (s *Store) Publish(ctx context.Context, owner int64, sqid string) (string, error) { |
| doc, err := s.Get(ctx, owner, sqid) |
| if err != nil { |
| - return nil, err |
| + return "", err |
| } |
| token, err := newToken() |
| if err != nil { |
| - return nil, err |
| + return "", err |
| } |
| - link := Link{DocID: doc.ID, Token: token, CreatedAt: time.Now().UTC()} |
| + link := Link{DocID: doc.ID, TokenHash: hashToken(token), CreatedAt: time.Now().UTC()} |
| // Delete-then-insert rather than an upsert on doc_id: the old token |
| // must stop working, and a row whose token changed and a row that was |
| // replaced are the same outcome only if nothing else ever holds a |
| @@ -83,9 +106,11 @@ func (s *Store) Publish(ctx context.Context, owner int64, sqid string) (*Link, e |
| return tx.Create(&link).Error |
| }) |
| if err != nil { |
| - return nil, err |
| + return "", err |
| } |
| - return &link, nil |
| + // The plaintext token is returned here and never again: this is the |
| + // only moment it exists outside the caller's hand (ruling 16). |
| + return token, nil |
| } |
| // Unpublish turns it off. Like Revoke, unpublishing something that is not |
| @@ -99,27 +124,27 @@ func (s *Store) Unpublish(ctx context.Context, owner int64, sqid string) error { |
| return s.db.WithContext(ctx).Where("doc_id = ?", doc.ID).Delete(&Link{}).Error |
| } |
| -// LinkOf returns a document's live link, or nil if it is not published. |
| +// IsPublished reports whether a document currently has a public link. |
| +// |
| +// It answers a bool and not a link, and that is ruling 16 showing up in a |
| +// signature: there is no way to ask this store for a published document's |
| +// URL, because the store does not have it. The share page can say "this is |
| +// published" and offer to replace or stop it; it cannot show the URL |
| +// again, and no future caller can be tempted to make it. |
| +// |
| // Owner-scoped through Get: whether a document is published is part of the |
| // share page, which is the owner's alone. |
| -// |
| -// A missing link is (nil, nil) rather than an error, because "not |
| -// published" is an ordinary state of an ordinary document and the share |
| -// page renders it as a switch that is off. |
| -func (s *Store) LinkOf(ctx context.Context, owner int64, sqid string) (*Link, error) { |
| +func (s *Store) IsPublished(ctx context.Context, owner int64, sqid string) (bool, error) { |
| doc, err := s.Get(ctx, owner, sqid) |
| if err != nil { |
| - return nil, err |
| - } |
| - var link Link |
| - err = s.db.WithContext(ctx).Where("doc_id = ?", doc.ID).First(&link).Error |
| - if errors.Is(err, gorm.ErrRecordNotFound) { |
| - return nil, nil |
| + return false, err |
| } |
| - if err != nil { |
| - return nil, err |
| + var count int64 |
| + if err := s.db.WithContext(ctx).Model(&Link{}). |
| + Where("doc_id = ?", doc.ID).Count(&count).Error; err != nil { |
| + return false, err |
| } |
| - return &link, nil |
| + return count > 0, nil |
| } |
| // ByToken resolves a public link to its document, for the one route in |
| @@ -141,7 +166,8 @@ func (s *Store) ByToken(ctx context.Context, token string) (*Document, error) { |
| var doc Document |
| err := s.db.WithContext(ctx). |
| Joins("JOIN links ON links.doc_id = documents.id"). |
| - Where("links.token = ? AND documents.deleted_at IS NULL", token). |
| + Where("links.token_hash = ? AND documents.deleted_at IS NULL AND documents.owner_id > 0", |
| + hashToken(token)). |
| First(&doc).Error |
| if err != nil { |
| return nil, err |
diff --git a/internal/docs/migrations/0005_grants.sql b/internal/docs/migrations/0005_grants.sql| index c6a684f..200481b 100644 |
| --- a/internal/docs/migrations/0005_grants.sql |
| +++ b/internal/docs/migrations/0005_grants.sql |
| @@ -25,11 +25,22 @@ |
| -- The UNIQUE index is the whole conflict story: granting twice to the same |
| -- address on the same document updates the role rather than stacking two rows |
| -- whose precedence nobody has decided. |
| +-- `created_by` is the person who made the grant, and it is in the spec's own |
| +-- data model (§4) — this table shipped without it, and the omission was never |
| +-- argued, only overlooked while the nullable `doc_id` above was argued at |
| +-- length. It is the one column that answers "who let this address in". |
| +-- |
| +-- That question has teeth because admission is transitive in shape even where |
| +-- it is closed in policy: a guest is admitted by somebody's grant, and if a |
| +-- guest ever gains the power to grant, the chain of who admitted whom is only |
| +-- reconstructable if each link recorded its author. Adding it now, while the |
| +-- table is new, costs a line; adding it after a leak costs the history. |
| CREATE TABLE IF NOT EXISTS grants ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| doc_id INTEGER NOT NULL, |
| subject TEXT NOT NULL, |
| role TEXT NOT NULL, |
| + created_by INTEGER NOT NULL DEFAULT 0, |
| created_at DATETIME |
| ); |
diff --git a/internal/docs/migrations/0006_links.sql b/internal/docs/migrations/0006_links.sql| index a117339..518bd1e 100644 |
| --- a/internal/docs/migrations/0006_links.sql |
| +++ b/internal/docs/migrations/0006_links.sql |
| @@ -9,10 +9,27 @@ |
| -- edit" is already no (Paul's own Sheets ruling 12). The column that cannot |
| -- exist is the cheapest way to keep that decision. |
| -- |
| --- `token` is the capability itself: 128 bits from crypto/rand, base64url, and |
| --- never derived from doc_id, sqid, title or clock. A guessable token is the |
| --- whole feature failing silently, and a derived one is guessable by anybody |
| --- who reads this file. |
| +-- `token_hash` is a SHA-256 of the capability, never the capability itself |
| +-- (suite doctrine ruling 16, following Sheets spec §8.3). The token is 128 |
| +-- bits from crypto/rand, base64url, never derived from doc_id, sqid, title or |
| +-- clock — a guessable token is the whole feature failing silently, and a |
| +-- derived one is guessable by anybody who reads this file. |
| +-- |
| +-- Stored as a hash because a token is a STANDING KEY where a document is a |
| +-- snapshot. Leaked content is what it was on the day it leaked; a leaked token |
| +-- keeps opening the live document, every later revision, until somebody |
| +-- notices. Hashing turns a backup or a replica from an ongoing capability into |
| +-- a one-time disclosure. That argument holds here even though this app stores |
| +-- its content in plaintext, which is the tempting reason to skip it. |
| +-- |
| +-- The consequence is that the app cannot show the URL twice. It is shown once, |
| +-- in the response to the publish that minted it, and after that the share page |
| +-- can say only whether a link exists. An owner who loses it replaces it. |
| +-- |
| +-- Unsalted, deliberately: a salt defends a small input space and there is not |
| +-- one here — the token's own 128 bits are the salt. SHA-256 rather than a |
| +-- password hash for the same reason, and because a public page load should not |
| +-- do key-stretching work. |
| -- |
| -- UNIQUE(doc_id): one live link per document, so "the link" is never an |
| -- ambiguous phrase in the UI or in an incident. Turning publishing off DELETEs |
| @@ -26,12 +43,13 @@ |
| CREATE TABLE IF NOT EXISTS links ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| doc_id INTEGER NOT NULL, |
| - token TEXT NOT NULL, |
| + token_hash TEXT NOT NULL, |
| created_at DATETIME |
| ); |
| CREATE UNIQUE INDEX IF NOT EXISTS idx_links_doc ON links (doc_id); |
| -- The public read path has only a token to go on, so the lookup that has to be |
| --- fast — and single-row — leads on it. |
| -CREATE UNIQUE INDEX IF NOT EXISTS idx_links_token ON links (token); |
| +-- fast — and single-row — leads on its hash. Byte-equality on an indexed |
| +-- column is not a timing concern at 128 bits of entropy. |
| +CREATE UNIQUE INDEX IF NOT EXISTS idx_links_token_hash ON links (token_hash); |
diff --git a/internal/docs/oneshot.go b/internal/docs/oneshot.go| new file mode 100644 |
| index 0000000..04e4331 |
| --- /dev/null |
| +++ b/internal/docs/oneshot.go |
| @@ -0,0 +1,84 @@ |
| +package docs |
| + |
| +import ( |
| + "fmt" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// oneShotTTL is how long a minted public URL waits to be collected. Long |
| +// enough to survive a 303 and a slow render, short enough that a URL left |
| +// behind by a browser that never followed the redirect is gone before |
| +// anybody could come back to the machine and find it. |
| +const oneShotTTL = 30 * time.Second |
| + |
| +// oneShots holds each freshly minted public URL for exactly one read. |
| +// |
| +// It exists because of ruling 16 and the redirect, together. The token is |
| +// shown once and the database cannot produce it again, so the share page |
| +// has to be handed it — but sharePost answers 303 (a POST that renders its |
| +// own page is a POST a reload re-submits, and here a re-submit would |
| +// rotate the link the person is in the middle of copying). So the URL has |
| +// to survive one redirect and no longer. |
| +// |
| +// In memory, and that is consistent rather than lazy: this app is one |
| +// process on one SQLite file, which is the same assumption hub and |
| +// previews already make. A restart between the POST and the GET loses the |
| +// URL; the owner republishes, which is the same recovery as losing it any |
| +// other way, and no worse than the alternative of writing the plaintext |
| +// token to the database — which is the whole thing being avoided. |
| +type oneShots struct { |
| + mu sync.Mutex |
| + items map[string]oneShot |
| +} |
| + |
| +type oneShot struct { |
| + value string |
| + expires time.Time |
| +} |
| + |
| +func newOneShots() *oneShots { return &oneShots{items: map[string]oneShot{}} } |
| + |
| +// key scopes a pending URL to one person and one document, so a second |
| +// person on the same document — or the same person on another — can never |
| +// collect it. |
| +func oneShotKey(person int64, sqid string) string { |
| + return fmt.Sprintf("%d:%s", person, sqid) |
| +} |
| + |
| +// put stores value under key, replacing anything already there. Publishing |
| +// twice in a row leaves the newer URL, which is the one that works. |
| +func (o *oneShots) put(key, value string) { |
| + o.mu.Lock() |
| + defer o.mu.Unlock() |
| + o.sweepLocked() |
| + o.items[key] = oneShot{value: value, expires: time.Now().Add(oneShotTTL)} |
| +} |
| + |
| +// take returns the value and removes it. A second read returns "", which |
| +// is what makes this show-once rather than show-until-expiry: a reload of |
| +// the share page after collecting the URL shows the published state and no |
| +// URL, exactly as a visit tomorrow would. |
| +func (o *oneShots) take(key string) string { |
| + o.mu.Lock() |
| + defer o.mu.Unlock() |
| + item, ok := o.items[key] |
| + delete(o.items, key) |
| + if !ok || time.Now().After(item.expires) { |
| + return "" |
| + } |
| + return item.value |
| +} |
| + |
| +// sweepLocked drops expired entries. Called on put rather than on a timer: |
| +// the map only grows when somebody publishes, so that is the only moment |
| +// it can need clearing, and a background goroutine for a map this size |
| +// would be more machinery than the problem. |
| +func (o *oneShots) sweepLocked() { |
| + now := time.Now() |
| + for k, v := range o.items { |
| + if now.After(v.expires) { |
| + delete(o.items, k) |
| + } |
| + } |
| +} |
diff --git a/internal/docs/publiclink_test.go b/internal/docs/publiclink_test.go| index 65d46ae..07bdb76 100644 |
| --- a/internal/docs/publiclink_test.go |
| +++ b/internal/docs/publiclink_test.go |
| @@ -17,15 +17,22 @@ func anon(t *testing.T, app *docs.App, path string) *http.Response { |
| return rec.Result() |
| } |
| -// linkFor turns the link on for a document and returns its public path, |
| -// read back off the share page rather than out of the store — so these |
| -// tests exercise the same route a person would copy out of the UI. |
| +// linkFor publishes a document and returns its public path, collected the |
| +// one way the app offers it: the share page rendered immediately after the |
| +// publish. Under ruling 16 the database holds a SHA-256 and cannot produce |
| +// a URL, so there is no second chance and no store-level shortcut — the |
| +// test has to take the same one shot a person does. |
| func linkFor(t *testing.T, app *docs.App, cookies []*http.Cookie, sqid string) string { |
| t.Helper() |
| if rec := post(t, app, cookies, "/d/"+sqid+"/share/link", "on=1"); rec.Code != http.StatusSeeOther { |
| t.Fatalf("POST link: status %d, want 303", rec.Code) |
| } |
| - body := get(t, app, cookies, "/d/"+sqid+"/share").Body.String() |
| + return readLink(t, get(t, app, cookies, "/d/"+sqid+"/share").Body.String()) |
| +} |
| + |
| +// readLink pulls a /p/... path out of a rendered share page. |
| +func readLink(t *testing.T, body string) string { |
| + t.Helper() |
| i := strings.Index(body, "/p/") |
| if i < 0 { |
| t.Fatal("the share page does not show the public link it just created") |
| @@ -80,8 +87,12 @@ func TestAPublicLinkCarriesNoWrite(t *testing.T) { |
| } |
| // And the public page offers no editor to drive one with. |
| + // |
| + // "editor-entry" used to be in this list and appears on no page in |
| + // this app — a ban that could never fail. The mount element and the |
| + // real served script path are what actually matter. |
| body := get(t, app, nil, linkFor(t, app, alice, sqid)).Body.String() |
| - for _, banned := range []string{"docs-editor", "editor-entry", "/updates"} { |
| + for _, banned := range []string{"docs-text docs-editor", "data-sqid", docs.TestEditorScriptPath(), "/updates"} { |
| if strings.Contains(body, banned) { |
| t.Errorf("the public page ships %q; it is a reading surface", banned) |
| } |
| @@ -98,12 +109,128 @@ func TestAWrongTokenIsANotFound(t *testing.T) { |
| path := linkFor(t, app, alice, sqid) |
| for _, bad := range []string{"/p/", "/p/nope", "/p/" + strings.Repeat("a", 43), path + "x"} { |
| - if res := anon(t, app, bad); res.StatusCode == http.StatusOK { |
| - t.Errorf("GET %s answered 200", bad) |
| + if res := anon(t, app, bad); res.StatusCode != http.StatusNotFound { |
| + t.Errorf("GET %s answered %d, want exactly 404", bad, res.StatusCode) |
| } |
| } |
| } |
| +// The refusals are byte-identical, which is the invariant the branch |
| +// claims and nothing previously checked. The old test asserted only |
| +// "!= 200", so a 500 from a database error would have passed it while |
| +// telling a stranger that this token means something. |
| +// |
| +// Unknown, revoked, and pointing-at-a-deleted-document must be one answer. |
| +// The differences between them are exactly the oracle §4.2 exists to deny: |
| +// which documents were ever published, and which were withdrawn. |
| +func TestPublicRefusalsAreIndistinguishable(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + |
| + type answer struct{ status, body, ctype string } |
| + capture := func(path string) answer { |
| + rec := get(t, app, nil, path) |
| + return answer{ |
| + status: rec.Result().Status, |
| + body: rec.Body.String(), |
| + ctype: rec.Header().Get("Content-Type"), |
| + } |
| + } |
| + |
| + // (i) a syntactically valid token that was never minted. |
| + unknown := capture("/p/" + strings.Repeat("A", 22)) |
| + |
| + // (ii) a real token, revoked. |
| + revokedSqid := createDoc(t, app, alice, "Revoked") |
| + revokedPath := linkFor(t, app, alice, revokedSqid) |
| + post(t, app, alice, "/d/"+revokedSqid+"/share/link", "on=0") |
| + revoked := capture(revokedPath) |
| + |
| + // (iii) a live token whose document has been deleted underneath it. |
| + deletedSqid := createDoc(t, app, alice, "Deleted") |
| + deletedPath := linkFor(t, app, alice, deletedSqid) |
| + app.TestSoftDelete(t, deletedSqid) |
| + deleted := capture(deletedPath) |
| + |
| + if unknown.status != "404 Not Found" { |
| + t.Fatalf("an unknown token answered %q, want 404", unknown.status) |
| + } |
| + for name, got := range map[string]answer{"revoked": revoked, "deleted-document": deleted} { |
| + if got != unknown { |
| + t.Errorf("%s answers differently from an unknown token:\n got: %+v\nwant: %+v", name, got, unknown) |
| + } |
| + } |
| +} |
| + |
| +// The published page shows the document's actual content. |
| +// |
| +// Every other test here publishes an EMPTY document, so if ByToken's join |
| +// ever scanned links.id into Document.ID the wrong document would be |
| +// replayed and every one of them would still pass with a 200. GORM emits |
| +// SELECT documents.* here today; nothing held it. |
| +func TestAPublicLinkShowsTheDocumentsContent(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + app.TestWrite(t, alice, sqid, "hello") |
| + |
| + body := get(t, app, nil, linkFor(t, app, alice, sqid)).Body.String() |
| + if !strings.Contains(body, "hello") { |
| + t.Error("the published page does not contain the document's text") |
| + } |
| + if strings.Contains(body, sqid) { |
| + t.Error("the published page names the private sqid") |
| + } |
| +} |
| + |
| +// A published URL is shown once and never again — ruling 16 as a |
| +// behaviour rather than as a column type. Reloading the share page after |
| +// collecting it must show the document as published WITHOUT the URL. |
| +func TestThePublicURLIsShownExactlyOnce(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + |
| + path := linkFor(t, app, alice, sqid) // consumes the one showing |
| + |
| + again := get(t, app, alice, "/d/"+sqid+"/share").Body.String() |
| + if strings.Contains(again, path) { |
| + t.Error("the share page showed the public URL a second time") |
| + } |
| + if strings.Contains(again, "/p/") { |
| + t.Error("the share page still prints a /p/ URL after the one showing") |
| + } |
| + // But it still says the document is published, or the owner cannot |
| + // tell a published document from an unpublished one. |
| + // |
| + // The catalog key, not the word: NewForTest wires no BaseCatalog, so |
| + // strings render as their keys — and "published" matched the key |
| + // "docs.share.public.published" whatever the page actually said, |
| + // which made this assertion unfalsifiable. |
| + if !strings.Contains(again, "docs.share.public.published") { |
| + t.Error("the share page does not say the document is published") |
| + } |
| + // And the link itself still works — shown once, not valid once. |
| + if res := anon(t, app, path); res.StatusCode != http.StatusOK { |
| + t.Errorf("the link stopped working after being shown: %d", res.StatusCode) |
| + } |
| +} |
| + |
| +// Nothing anywhere stores the plaintext token. The point of ruling 16, |
| +// asserted against the database rather than against the code that writes |
| +// to it. |
| +func TestTheTokenIsNotStoredInPlaintext(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + path := linkFor(t, app, alice, sqid) |
| + token := strings.TrimPrefix(path, "/p/") |
| + |
| + if app.TestRowContains(t, "links", token) { |
| + t.Error("the plaintext token is in the links table") |
| + } |
| +} |
| + |
| // Turning it off is the revoke, and it has to be immediate: a link that |
| // keeps working after the owner switched it off is the failure that makes |
| // people distrust the whole control. |
| @@ -172,8 +299,15 @@ func TestOnlyTheOwnerMayPublish(t *testing.T) { |
| bob := signInCookies(t, app, "bob@example.com") |
| post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer") |
| - if rec := post(t, app, bob, "/d/"+sqid+"/share/link", "on=1"); rec.Code != http.StatusNotFound { |
| - t.Errorf("a writer grantee published the document: status %d, want 404", rec.Code) |
| + for _, c := range []struct{ path, body string }{ |
| + {"/d/" + sqid + "/share/link", "on=1"}, |
| + {"/d/" + sqid + "/share/link", "on=0"}, |
| + {"/d/" + sqid + "/share/revoke", "address=alice@example.com"}, |
| + {"/d/" + sqid + "/share", "address=eve@example.com&role=reader"}, |
| + } { |
| + if rec := post(t, app, bob, c.path, c.body); rec.Code != http.StatusNotFound { |
| + t.Errorf("a writer grantee reached POST %s (%s): status %d, want 404", c.path, c.body, rec.Code) |
| + } |
| } |
| } |
diff --git a/internal/docs/publish.go b/internal/docs/publish.go| index 9a3e2dc..48562dd 100644 |
| --- a/internal/docs/publish.go |
| +++ b/internal/docs/publish.go |
| @@ -61,11 +61,20 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - // No cache header beyond the default: a published document is meant |
| - // to be read, and a token in the path already makes the URL its own |
| - // cache key. What must not happen is a shared cache keeping a copy |
| - // after the link is revoked, which is why nothing here marks it |
| - // public or sets a max-age. |
| + // no-store, said rather than implied. The first version of this |
| + // handler set no header at all and its comment claimed that kept a |
| + // shared cache from retaining a revoked copy — which is not what an |
| + // absent Cache-Control means. Absent leaves it to heuristics; a |
| + // revoked link's page could sit in an intermediary. stream.go and |
| + // search.go already say no-store for exactly this reason. |
| + // |
| + // X-Robots-Tag because "anyone with the link" is not "anyone": a |
| + // capability that leaks into a crawlable place should not also become |
| + // a search result. It is not a security control — the link is the |
| + // authorization and a crawler that has it can read the page — it is a |
| + // refusal to help the leak spread. |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("X-Robots-Tag", "noindex, nofollow") |
| if err := h.render.page(w, r, "public", publicPage{ |
| Title: doc.Title, |
| HTML: html, |
| @@ -87,6 +96,12 @@ func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) { |
| // answered 404: publishing to everyone is the largest re-share there is, |
| // and no grant carries re-sharing. |
| func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) { |
| + // Member-only, as the other two share routes are. Publishing to the |
| + // whole internet is the largest re-share there is. |
| + if !h.member(r) { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + return |
| + } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve owner for publish", "err", err) |
| @@ -97,7 +112,14 @@ func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) { |
| on := r.PostFormValue("on") == "1" |
| if on { |
| - _, err = h.store.Publish(r.Context(), owner, sqid) |
| + var token string |
| + token, err = h.store.Publish(r.Context(), owner, sqid) |
| + if err == nil { |
| + // The only moment this URL exists outside the token itself. |
| + // Parked for exactly one render (oneshot.go) and never |
| + // written down, because the database holds only a hash. |
| + h.oneShots.put(oneShotKey(owner, sqid), h.origin+"/p/"+token) |
| + } |
| } else { |
| err = h.store.Unpublish(r.Context(), owner, sqid) |
| } |
diff --git a/internal/docs/reader_noeditor_test.go b/internal/docs/reader_noeditor_test.go| new file mode 100644 |
| index 0000000..f39abe7 |
| --- /dev/null |
| +++ b/internal/docs/reader_noeditor_test.go |
| @@ -0,0 +1,81 @@ |
| +package docs_test |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// A reader grantee must not be handed an editor. Found by the branch |
| +// review, and the worst defect on this branch: `show` used GetFor |
| +// (correct — a reader may read) and then rendered the owner's page |
| +// verbatim, so ProseMirror mounted, the toolbar enabled, and the document |
| +// became contenteditable for somebody the server will refuse. |
| +// |
| +// The server refusal was never in doubt: ApplyUpdate returns ErrNotWritable |
| +// and updatesPost answers 404. The damage is upstream of that. The browser |
| +// has already taken the keystrokes; the save queue classifies the 404 as |
| +// permanent, drops the batch, and keeps accepting typing. The person is |
| +// told "save failed" after writing a paragraph into a page that told them |
| +// they could. |
| +// |
| +// "It fails safely on the server" is not a defence when the cost is a |
| +// person's work. |
| +func TestAReaderIsNotGivenAnEditor(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + bob := signInCookies(t, app, "bob@example.com") |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader") |
| + |
| + rec := get(t, app, bob, "/d/"+sqid) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /d/%s as a reader: status %d, want 200", sqid, rec.Code) |
| + } |
| + body := rec.Body.String() |
| + |
| + // The mount ELEMENT, not the string: `[docs-editor]` also appears a |
| + // dozen times in this page's own <style> block, so banning the bare |
| + // word would fail on a page that is perfectly correct — and would |
| + // have hidden whether the element itself was gone. `docs-text |
| + // docs-editor` is the attribute pair the mount point carries and the |
| + // stylesheet never does. |
| + // |
| + // data-sqid is the bundle's only way to learn where to post, and the |
| + // editor script is checked by its real served path rather than a |
| + // guessed substring: the review found this test's earlier sibling |
| + // banning "editor-entry", a string that appears on no page in this |
| + // app, which is a ban that could never fail. |
| + for _, banned := range []string{"docs-text docs-editor", "data-sqid", docs.TestEditorScriptPath()} { |
| + if strings.Contains(body, banned) { |
| + t.Errorf("a reader's document page ships %q", banned) |
| + } |
| + } |
| + |
| + // The content is still there. A read-only page that renders nothing is |
| + // not the fix. |
| + if !strings.Contains(body, "Minutes") { |
| + t.Error("the reader's page does not show the document") |
| + } |
| +} |
| + |
| +// The writer keeps everything. Without this the fix above passes on a |
| +// page that gives nobody an editor. |
| +func TestAWriterKeepsTheEditor(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + bob := signInCookies(t, app, "bob@example.com") |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer") |
| + |
| + for who, cookies := range map[string][]*http.Cookie{"writer": bob, "owner": alice} { |
| + body := get(t, app, cookies, "/d/"+sqid).Body.String() |
| + for _, needed := range []string{"docs-text docs-editor", "data-sqid", docs.TestEditorScriptPath()} { |
| + if !strings.Contains(body, needed) { |
| + t.Errorf("the %s's page is missing %q", who, needed) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index fc96104..a581b5e 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -94,7 +94,19 @@ var Catalog = rastrillo.Catalog{ |
| "docs.share.public.body": "Publish a link that anyone can open to read this document. They will not be able to change it, and they will not need to sign in.", |
| "docs.share.public.on": "Publish a link", |
| "docs.share.public.off": "Stop publishing", |
| - "docs.share.public.hint": "Copy this link to share it. Publishing again later makes a new link, and this one stops working.", |
| + "docs.share.public.hint": "Publishing again makes a new link, and the old one stops working.", |
| + |
| + // docs.share.public.once is the one-time notice, and the copy has to |
| + // carry a real instruction because the app genuinely cannot repeat |
| + // itself: it stores a hash of the token, not the token (suite ruling |
| + // 16). "You will not see it again" is a fact here, not a scare. |
| + // |
| + // docs.share.public.published is what the section says on every later |
| + // visit — published, without the URL, which is the shape of the whole |
| + // ruling in one line of copy. |
| + "docs.share.public.once": "Copy this link now. This is the only time it will be shown.", |
| + "docs.share.public.published": "This document is published. The link was shown once when it was created and cannot be shown again — replace it if you no longer have it.", |
| + "docs.share.public.replace": "Replace the link", |
| // docs.public.readonly is the standing notice on the published page |
| // itself (views/public.html). It is there because a person arriving |
| @@ -222,6 +234,12 @@ var Catalog = rastrillo.Catalog{ |
| // bar's account menu, which list.html and show.html each fill with |
| // an "account" block; it was in their two page headers until the |
| // chrome moved to the topbar shell (see shellLayout above). |
| + // docs.list.shared marks a row on the list that belongs to somebody |
| + // else. It exists because the list stopped being "your documents" the |
| + // day sharing landed, and a row that looks like yours and is not is |
| + // how a person opens a document expecting to be able to edit it. |
| + "docs.list.shared": "Shared with you", |
| + |
| "docs.signout.label": "Sign out", |
| // docs.editor.status.* is the save-status indicator fix round 1's |
diff --git a/internal/docs/search.go b/internal/docs/search.go| index e06ca09..f1a0f83 100644 |
| --- a/internal/docs/search.go |
| +++ b/internal/docs/search.go |
| @@ -204,7 +204,7 @@ type searchResult struct { |
| // cannot drift from the one the list page uses (the plan's D3). It also |
| // means no bulk export of a person's titles to the browser. |
| // |
| -// Inside app.go's RequireSession group and owner-scoped through |
| +// Inside app.go's RequireSession group and reader-scoped through |
| // Search → List, so a document belonging to someone else cannot appear |
| // here — the same property every other document route has, by the same |
| // mechanism rather than by a check of its own. |
diff --git a/internal/docs/share.go b/internal/docs/share.go| index a4d8a93..7abc56e 100644 |
| --- a/internal/docs/share.go |
| +++ b/internal/docs/share.go |
| @@ -32,10 +32,23 @@ type sharePage struct { |
| // could disagree with it. |
| PublicHeading string |
| PublicBody string |
| + |
| + // Published is whether a link exists; PublicURL is the link itself and |
| + // is set only in the render immediately after publishing. |
| + // |
| + // Two fields rather than one because they are two different facts now: |
| + // under ruling 16 the app knows a document is published without being |
| + // able to say what its URL is. Collapsing them would make "published" |
| + // unrepresentable whenever the URL is unavailable — which is almost |
| + // always. |
| + Published bool |
| PublicURL string |
| PublishButton string |
| UnpublishBtn string |
| + ReplaceButton string |
| CopyHint string |
| + OnceWarning string |
| + PublishedBody string |
| } |
| // shareRow is one person who reaches this document. |
| @@ -76,17 +89,21 @@ func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) { |
| } |
| func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Document, grants []*Grant, errKey string) { |
| - // A failure to read the link is logged and then rendered as "not |
| - // published" rather than failing the page: the grant list above it is |
| - // the part somebody came here for, and a 500 over the switch would |
| + // A failure to read the published state is logged and then rendered as |
| + // "not published" rather than failing the page: the grant list above it |
| + // is the part somebody came here for, and a 500 over the switch would |
| // take the working half of the screen down with it. |
| - var publicURL string |
| - if link, err := h.store.LinkOf(r.Context(), doc.OwnerID, doc.Sqid); err != nil { |
| - h.logger.Error("docs: read public link", "sqid", doc.Sqid, "err", err) |
| - } else if link != nil { |
| - publicURL = h.origin + "/p/" + link.Token |
| + published, err := h.store.IsPublished(r.Context(), doc.OwnerID, doc.Sqid) |
| + if err != nil { |
| + h.logger.Error("docs: read published state", "sqid", doc.Sqid, "err", err) |
| } |
| + // The URL appears only in the render that follows the publish that |
| + // minted it (ruling 16: the database holds a hash and cannot produce a |
| + // URL). Collected here, once; a reload shows the published state |
| + // without it, exactly as a visit tomorrow would. |
| + publicURL := h.oneShots.take(oneShotKey(doc.OwnerID, doc.Sqid)) |
| + |
| rows := make([]shareRow, 0, len(grants)) |
| for _, g := range grants { |
| label := "docs.share.role.reader" |
| @@ -114,16 +131,23 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu |
| PublicHeading: rastrillo.T(r, "docs.share.public.heading"), |
| PublicBody: rastrillo.T(r, "docs.share.public.body"), |
| + Published: published, |
| PublicURL: publicURL, |
| PublishButton: rastrillo.T(r, "docs.share.public.on"), |
| UnpublishBtn: rastrillo.T(r, "docs.share.public.off"), |
| + ReplaceButton: rastrillo.T(r, "docs.share.public.replace"), |
| CopyHint: rastrillo.T(r, "docs.share.public.hint"), |
| + OnceWarning: rastrillo.T(r, "docs.share.public.once"), |
| + PublishedBody: rastrillo.T(r, "docs.share.public.published"), |
| } |
| if errKey != "" { |
| page.Error = rastrillo.T(r, errKey) |
| } |
| - h.render.page(w, r, "share", page) |
| + if err := h.render.page(w, r, "share", page); err != nil { |
| + h.logger.Error("docs: render share", "sqid", doc.Sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + } |
| } |
| // sharePost serves POST /d/{sqid}/share: grant an address access. |
| @@ -131,6 +155,14 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu |
| // Scriptless: a plain form with a named address field and a role radio, and a |
| // 303 back to this page afterwards so a reload does not re-post. |
| func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) { |
| + // Member-only, on top of the owner check below: changing who reaches |
| + // a document is a member power. A guest owns no documents (create is |
| + // closed to them), so this is belt and braces — and it is the brace |
| + // that holds if a guest ever acquires one. |
| + if !h.member(r) { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + return |
| + } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve owner for share", "err", err) |
| @@ -163,6 +195,10 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| return |
| } |
| + // Content-Type before WriteHeader, or the header render.page |
| + // sets afterwards is dropped and the response works only by |
| + // sniffing. |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.WriteHeader(http.StatusBadRequest) |
| h.renderShare(w, r, doc, grants, "docs.share.error.address") |
| default: |
| @@ -173,6 +209,14 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) { |
| // shareRevoke serves POST /d/{sqid}/share/revoke. |
| func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) { |
| + // Member-only, on top of the owner check below: changing who reaches |
| + // a document is a member power. A guest owns no documents (create is |
| + // closed to them), so this is belt and braces — and it is the brace |
| + // that holds if a guest ever acquires one. |
| + if !h.member(r) { |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| + return |
| + } |
| owner, err := h.owner(r) |
| if err != nil { |
| h.logger.Error("docs: resolve owner for revoke", "err", err) |
diff --git a/internal/docs/shared_list_test.go b/internal/docs/shared_list_test.go| index 2813afa..7ad59c4 100644 |
| --- a/internal/docs/shared_list_test.go |
| +++ b/internal/docs/shared_list_test.go |
| @@ -50,6 +50,46 @@ func TestASharedDocumentAppearsInTheGranteesList(t *testing.T) { |
| } |
| } |
| +// A shared row says it is shared, and the reader's own rows do not. |
| +// |
| +// The list stopped being "your documents" the day sharing landed. A row |
| +// that looks like yours and is not is how somebody opens a document |
| +// expecting to edit it — and with a reader grant they now get a page with |
| +// no editor, which is correct and needs explaining before the click, not |
| +// after it. (Branch review, finding 9.) |
| +func TestASharedRowSaysSo(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Alices") |
| + bob := signInCookies(t, app, "bob@example.com") |
| + createDoc(t, app, bob, "Bobs") |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader") |
| + |
| + // The catalog KEY, not the English. NewForTest's app never wires |
| + // rastrillo.Options.BaseCatalog (only cmd/docs/main.go does), so every |
| + // catalog string renders as its own key here. Asserting the prose |
| + // would be asserting a string that never appears — and, worse, a |
| + // prose fragment can accidentally match a key: a nearby test checked |
| + // for "published" and passed on the key "docs.share.public.published" |
| + // rather than on anything being published. |
| + const marker = "docs.list.shared" |
| + |
| + body := get(t, app, bob, "/").Body.String() |
| + if !strings.Contains(body, marker) { |
| + t.Error("the shared row carries no marker") |
| + } |
| + // Exactly one row is marked: Bob's own must not be. A list where |
| + // every row is badged says nothing. |
| + if n := strings.Count(body, marker); n != 1 { |
| + t.Errorf("%d rows marked shared, want exactly 1", n) |
| + } |
| + |
| + // And Alice, who owns hers, sees no marker at all. |
| + if strings.Contains(get(t, app, alice, "/").Body.String(), marker) { |
| + t.Error("the owner's own document is marked as shared with her") |
| + } |
| +} |
| + |
| // Revoking takes it back out of the list, not just out of the document. A |
| // revoke that left the row on the grantee's list would leave them clicking |
| // into a 404 and reading it as a bug. |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 52cba00..5ef83f9 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -29,6 +29,20 @@ type Store struct { |
| // gate lives here rather than at the HTTP layer. |
| sampleDocuments bool |
| + // seedOnlyMembers, when set, keeps the sample library away from a |
| + // person admitted only by a grant. |
| + // |
| + // The sample documents are "welcome to your instance" content. Somebody |
| + // invited to read one document is not arriving at their instance; they |
| + // are arriving at somebody else's, to read one thing. Seeding them six |
| + // documents of ours is the outcome 0005_grants.sql's comment says the |
| + // design avoids — it just avoided it at grant time and then did it at |
| + // sign-in time instead. (Branch review, finding 9.) |
| + // |
| + // nil means "seed everybody", which is what every construction without |
| + // an allowlist gets, and matches sampleDocuments' own default shape. |
| + seedOnlyMembers func(address string) bool |
| + |
| // logger is where a sample-seeding failure goes — see |
| // seedIfEnabled's own doc comment for why that failure is logged |
| // and swallowed here rather than ever returned to EnsurePerson's |
| @@ -242,6 +256,9 @@ func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string) |
| if !s.sampleDocuments { |
| return |
| } |
| + if s.seedOnlyMembers != nil && !s.seedOnlyMembers(email) { |
| + return |
| + } |
| if err := Seed(ctx, s, Actor{PersonID: newOwner, Subject: email}); err != nil { |
| s.logger.Error("docs: seed sample documents for new person", "person", newOwner, "address", email, "err", err) |
| } |
| @@ -693,7 +710,7 @@ func (s *Store) currentSeq(ctx context.Context, docID int64) (int64, error) { |
| // is to refuse the entire operation before any of them run. doc is a |
| // value a caller can construct or mutate freely (as the regression |
| // test below does); it is not a database guarantee. Store.Get is |
| -// owner-scoped, so doc.OwnerID is trustworthy *when doc came from |
| +// reader-scoped, so doc.OwnerID is trustworthy *when doc came from |
| // Get* — but nothing at this call site enforces that it did, which is |
| // the entire defect. So rather than trust the struct, this re-derives |
| // ownership from the database itself: look up document doc.ID through |
| @@ -724,7 +741,7 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd |
| // This check used to be owned(db, doc.OwnerID).Where("id = ?", doc.ID) — |
| // the document against its own owner, which is true of every document by |
| // construction. That was harmless only because the sole way to hold a |
| - // *Document was to have fetched it through an owner-scoped query, so this |
| + // *Document was to have fetched it through a scoped query, so this |
| // was a belt on top of the read scope's braces. Sharing removes that |
| // guarantee: a reader can now legitimately fetch a document, and the old |
| // check would have waved their write through, because it never asked |
diff --git a/internal/docs/stream.go b/internal/docs/stream.go| index 35ce48e..bd73b35 100644 |
| --- a/internal/docs/stream.go |
| +++ b/internal/docs/stream.go |
| @@ -55,7 +55,7 @@ const ( |
| // opaque binary Yjs updates on it would have meant base64 and about a |
| // third more bytes on every keystroke batch. |
| // |
| -// Owner-scoped through getDocumentForUpdates, so a stranger gets the |
| +// Reader-scoped through getDocumentForUpdates, so a stranger gets the |
| // same 404 the update routes give — never a 403, which would confirm |
| // the document exists. |
| func (h *handlers) stream(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/docs/testing.go b/internal/docs/testing.go| index 73b4395..29562f2 100644 |
| --- a/internal/docs/testing.go |
| +++ b/internal/docs/testing.go |
| @@ -2,6 +2,7 @@ package docs |
| import ( |
| "context" |
| + "database/sql" |
| "fmt" |
| "io" |
| "net/http" |
| @@ -11,6 +12,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| ) |
| // NewForTest builds an App against a temporary SQLite database. It is |
| @@ -26,12 +28,34 @@ import ( |
| // sign-in landed and was deleted in fix round 1 on Task 1 — every |
| // test built on NewForTest signs in for real via TestSignIn now. |
| func NewForTest(t *testing.T) (*App, error) { |
| + return NewForTestWith(t, Config{}) |
| +} |
| + |
| +// NewForTestWith is NewForTest with the caller's own Config, for the tests |
| +// that need a real allowlist. |
| +// |
| +// It exists because NewForTest fixes an empty AllowedAddresses, which in |
| +// development means "admit everyone" (resolveAllowlist) — so the whole |
| +// grant-admission path, and the guest/member distinction built on it, were |
| +// unreachable from any test in the suite. The branch review found exactly |
| +// that hole: allows() and HasGrant were each tested in isolation, and the |
| +// wiring that makes them one thing was not, so moving one line in app.go |
| +// would have silently recreated the unredeemable invitation its comment |
| +// warns about, green. (Branch review, finding 7e.) |
| +// |
| +// DBPath and Env are filled in when the caller leaves them empty, so a test |
| +// that only wants an allowlist writes only an allowlist. |
| +func NewForTestWith(t *testing.T, cfg Config) (*App, error) { |
| t.Helper() |
| - app, err := New(Config{ |
| - DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| - Env: "development", |
| - }) |
| + if cfg.DBPath == "" { |
| + cfg.DBPath = filepath.Join(t.TempDir(), "docs.db") |
| + } |
| + if cfg.Env == "" { |
| + cfg.Env = "development" |
| + } |
| + |
| + app, err := New(cfg) |
| if err != nil { |
| return nil, err |
| } |
| @@ -66,6 +90,12 @@ func (a *App) TestSignIn(t *testing.T, address string) (string, error) { |
| return "", fmt.Errorf("docs: TestSignIn requires Env %q; app was built with Env %q", "development", a.cfg.Env) |
| } |
| + // Snapshot the send count before the POST: the check below has to see |
| + // a NEW mail, not merely a recorder whose last occupant matches. |
| + a.mailRecorder.mu.Lock() |
| + before := a.mailRecorder.sends |
| + a.mailRecorder.mu.Unlock() |
| + |
| form := url.Values{"address": {address}, "force": {"1"}} |
| req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| @@ -88,9 +118,22 @@ func (a *App) TestSignIn(t *testing.T, address string) (string, error) { |
| } |
| a.mailRecorder.mu.Lock() |
| - body := a.mailRecorder.lastBody |
| + to, body, after := a.mailRecorder.lastTo, a.mailRecorder.lastBody, a.mailRecorder.sends |
| a.mailRecorder.mu.Unlock() |
| + // A refused address gets the same 303 ?sent=1 a real one gets |
| + // (gateBegin, deliberately, so the endpoint is not a membership |
| + // oracle). So the only evidence that this address was admitted is that |
| + // a new mail went out, to it. Both halves are needed: the count alone |
| + // would accept somebody else's mail, and the recipient alone stays |
| + // true for every later refusal of an address that once succeeded. |
| + if after == before { |
| + return "", fmt.Errorf("docs: TestSignIn: no mail was sent for %q — the address was refused", address) |
| + } |
| + if normalizeAddress(to) != normalizeAddress(address) { |
| + return "", fmt.Errorf("docs: TestSignIn: the mail went to %q, not %q", to, address) |
| + } |
| + |
| link := magicLinkPattern.FindString(body) |
| if link == "" { |
| return "", fmt.Errorf("docs: TestSignIn: no magic link found in the mailed body for %q", address) |
| @@ -238,3 +281,78 @@ func (a *App) TestActorForPerson(t *testing.T, person int64) Actor { |
| } |
| return Actor{PersonID: p.ID, Subject: p.Email} |
| } |
| + |
| +// TestEditorScriptPath is the served path of the editor bundle, exported so |
| +// an external test can assert a page does or does not ship it. |
| +// |
| +// It exists because the branch review found a test banning the string |
| +// "editor-entry" from the public page — a string that appears on no page in |
| +// this app, so the ban could never fail. A test that names the real path |
| +// cannot drift from what staticHandler actually serves, because it is the |
| +// same function that builds the <script src>. |
| +func TestEditorScriptPath() string { return editorScriptPath() } |
| + |
| +// TestSoftDelete marks a document deleted the way a delete verb would, for |
| +// tests that need a live link pointing at a document that is no longer |
| +// there. There is no delete route yet; this is the state the schema |
| +// already models (documents.deleted_at) and that ByToken filters on. |
| +func (a *App) TestSoftDelete(t *testing.T, sqid string) { |
| + t.Helper() |
| + err := a.store.db.Model(&Document{}). |
| + Where("sqid = ?", sqid). |
| + Update("deleted_at", time.Now().UTC()).Error |
| + if err != nil { |
| + t.Fatalf("docs: TestSoftDelete(%q): %v", sqid, err) |
| + } |
| +} |
| + |
| +// TestWrite applies one update to a document as the signed-in owner, |
| +// through the same store path a real edit takes, so a test can publish a |
| +// document that actually has text in it. |
| +func (a *App) TestWrite(t *testing.T, cookies []*http.Cookie, sqid, fixture string) { |
| + t.Helper() |
| + var doc Document |
| + if err := a.store.db.Where("sqid = ?", sqid).First(&doc).Error; err != nil { |
| + t.Fatalf("docs: TestWrite: find %q: %v", sqid, err) |
| + } |
| + actor := a.TestActorFor(t, &doc) |
| + if _, err := a.store.ApplyUpdate(context.Background(), &doc, actor, ReadFragmentFixture(t, fixture)); err != nil { |
| + t.Fatalf("docs: TestWrite(%q): %v", fixture, err) |
| + } |
| +} |
| + |
| +// TestRowContains reports whether any column of any row in table holds |
| +// needle as a substring. |
| +// |
| +// It exists for the one assertion that cannot be made against behaviour: |
| +// ruling 16 says the plaintext token is not in the database, and the only |
| +// honest way to check that is to look in the database. A test that asked |
| +// the store instead would be asking the code that is supposed to be wrong. |
| +func (a *App) TestRowContains(t *testing.T, table, needle string) bool { |
| + t.Helper() |
| + rows, err := a.store.db.Raw("SELECT * FROM " + table).Rows() |
| + if err != nil { |
| + t.Fatalf("docs: TestRowContains: %v", err) |
| + } |
| + defer rows.Close() |
| + |
| + cols, err := rows.Columns() |
| + if err != nil { |
| + t.Fatalf("docs: TestRowContains: columns: %v", err) |
| + } |
| + for rows.Next() { |
| + cells := make([]any, len(cols)) |
| + for i := range cells { |
| + cells[i] = new(sql.RawBytes) |
| + } |
| + if err := rows.Scan(cells...); err != nil { |
| + t.Fatalf("docs: TestRowContains: scan: %v", err) |
| + } |
| + for _, c := range cells { |
| + if strings.Contains(string(*(c.(*sql.RawBytes))), needle) { |
| + return true |
| + } |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index 27806b2..fe99b7b 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -328,7 +328,7 @@ |
| <div rst-row> |
| {{template "thumb" .Thumb}} |
| <span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span> |
| - <span docs-row-when>{{.When}}</span> |
| + <span docs-row-when>{{.When}}</span>{{if .SharedNote}}<span rst-muted docs-row-shared>{{.SharedNote}}</span>{{end}} |
| </div> |
| {{end}} |
| </div> |
| @@ -347,7 +347,7 @@ |
| <div rst-row> |
| {{template "thumb" .Thumb}} |
| <span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span> |
| - <span docs-row-when>{{.When}}</span> |
| + <span docs-row-when>{{.When}}</span>{{if .SharedNote}}<span rst-muted docs-row-shared>{{.SharedNote}}</span>{{end}} |
| </div> |
| {{end}} |
| {{end}} |
diff --git a/internal/docs/views/public.html b/internal/docs/views/public.html| index 2d28f4d..c85172b 100644 |
| --- a/internal/docs/views/public.html |
| +++ b/internal/docs/views/public.html |
| @@ -1,3 +1,4 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| {{/* The published document: the one screen in this app a stranger sees. |
| No "account" block is defined, and that is deliberate rather than an |
diff --git a/internal/docs/views/share.html b/internal/docs/views/share.html| index 2a42a32..86f0545 100644 |
| --- a/internal/docs/views/share.html |
| +++ b/internal/docs/views/share.html |
| @@ -1,3 +1,4 @@ |
| +{{define "title"}}{{.Title}} — {{.DocTitle}}{{end}} |
| {{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}} |
| {{/* The share screen. Scriptless throughout, per spec §6.2, which lists |
| @@ -85,13 +86,26 @@ |
| <h2>{{.PublicHeading}}</h2> |
| <p rst-muted>{{.PublicBody}}</p> |
| + {{if .Published}} |
| + {{/* The URL appears here only in the render that follows the publish |
| + that minted it — the database holds a SHA-256 and cannot produce |
| + a URL (suite ruling 16). Afterwards this section says the |
| + document is published and offers to replace or stop it. */}} |
| {{if .PublicURL}} |
| + <p rst-notice docs-share-once role="status">{{.OnceWarning}}</p> |
| <p docs-share-url><code>{{.PublicURL}}</code></p> |
| + {{else}} |
| + <p rst-muted docs-share-state>{{.PublishedBody}}</p> |
| + {{end}} |
| <p rst-muted>{{.CopyHint}}</p> |
| <form method="post" action="/d/{{.Sqid}}/share/link"> |
| <input type="hidden" name="on" value="0"> |
| <button type="submit">{{.UnpublishBtn}}</button> |
| </form> |
| + <form method="post" action="/d/{{.Sqid}}/share/link"> |
| + <input type="hidden" name="on" value="1"> |
| + <button type="submit">{{.ReplaceButton}}</button> |
| + </form> |
| {{else}} |
| <form method="post" action="/d/{{.Sqid}}/share/link"> |
| <input type="hidden" name="on" value="1"> |
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html| index ccc4b71..b0735bd 100644 |
| --- a/internal/docs/views/show.html |
| +++ b/internal/docs/views/show.html |
| @@ -1324,7 +1324,7 @@ |
| controls, so it was the one place a person would not look. They are |
| still ordinary links to ordinary URLs, so both still work with no |
| JavaScript at all — spec §6.2 — and both are GETs of a document the |
| - signed-in person already owns, so neither needs a form or a CSRF |
| + signed-in person may already read, so neither needs a form or a CSRF |
| token. download tells the browser to save rather than navigate; the |
| filename it uses comes from the server's Content-Disposition, not |
| from this attribute, because a title is arbitrary text a person |
| @@ -1538,7 +1538,13 @@ |
| TestShowRendersContentWithoutScript pins it. data-sqid is the |
| bundle's only way to learn which document's update log |
| (/d/{{.Sqid}}/updates) to load and post to. */}} |
| - <div docs-text docs-editor data-sqid="{{.Sqid}}">{{.HTML}}</div> |
| + {{/* A reader gets the same container without the mount point, the |
| + data-sqid or (below) the script — absent, not disabled, because a |
| + disabled editor is one CSS rule from being an editor again. The |
| + server-rendered HTML is identical either way, so this is the |
| + scriptless read view the whole page already falls back to. */}} |
| + {{if .CanWrite}}<div docs-text docs-editor data-sqid="{{.Sqid}}">{{.HTML}}</div> |
| + {{else}}<div docs-text>{{.HTML}}</div>{{end}} |
| </div> |
| </div> |
| {{/* [docs-save-status] (fix round 1, F2): a sibling of [docs-editor], |
| @@ -1603,6 +1609,6 @@ |
| RequireSession group) and, if it throws, editor-entry.js's own catch |
| marks [docs-editor] data-editor-failed rather than clearing it — the |
| read view above stays exactly as rendered. */}} |
| -<script type="module" defer src="{{.EditorScript}}"></script> |
| +{{if .CanWrite}}<script type="module" defer src="{{.EditorScript}}"></script>{{end}} |
| {{template "docs-jumpto" .}} |
| {{end}} |
diff --git a/internal/docs/writer_http_test.go b/internal/docs/writer_http_test.go| new file mode 100644 |
| index 0000000..6658ec4 |
| --- /dev/null |
| +++ b/internal/docs/writer_http_test.go |
| @@ -0,0 +1,61 @@ |
| +package docs_test |
| + |
| +import ( |
| + "bytes" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// A writer grantee can actually write, over HTTP, and the owner sees it. |
| +// |
| +// The branch tested the writer's authorization at the store |
| +// (grants_write_test.go asserts "not ErrNotWritable" for a one-byte |
| +// update) and the reader's refusal at the route, and never the writer's |
| +// success at the route. So if updatesPost regressed to owner-only Get, |
| +// every existing test would still pass while collaborative editing — the |
| +// point of a writer grant — was silently dead. (Branch review, finding |
| +// 7d.) |
| +func TestAWriterCanPostUpdatesOverHTTP(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + bob := signInCookies(t, app, "bob@example.com") |
| + post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer") |
| + |
| + rec := postBytes(t, app, bob, "/d/"+sqid+"/updates", docs.ReadFragmentFixture(t, "hello")) |
| + if rec.Code != http.StatusNoContent { |
| + t.Fatalf("writer POST /updates: status %d, want 204", rec.Code) |
| + } |
| + |
| + // The owner sees what the writer wrote. Without this the test passes |
| + // on a 204 that dropped the update on the floor. |
| + if body := get(t, app, alice, "/d/"+sqid).Body.String(); !strings.Contains(body, "hello") { |
| + t.Error("the owner's page does not show the writer's text") |
| + } |
| + |
| + // And a grantee can read the update log, which is how the editor |
| + // catches up — also untested before. |
| + logRec := get(t, app, bob, "/d/"+sqid+"/updates") |
| + if logRec.Code != http.StatusOK || logRec.Body.Len() == 0 { |
| + t.Errorf("writer GET /updates: status %d, %d bytes", logRec.Code, logRec.Body.Len()) |
| + } |
| +} |
| + |
| +// postBytes is post for a binary body: the update routes take raw CRDT |
| +// bytes, not a form. |
| +func postBytes(t *testing.T, app *docs.App, cookies []*http.Cookie, path string, body []byte) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/octet-stream") |
| + req.Header.Set("Origin", docs.DefaultOrigin) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + return rec |
| +} |