Sharing, slice 3: a read-only link anyone can open
The second half of what Paul asked for — "being able to share read-only
publicly" — and the third rung of the suite doctrine's ruling 15.
GET /p/{token} is the one route here no session guards, and it sits
above the RequireSession group rather than inside it with an exception
carved into the middleware. That is what a capability is: it is
authorized by the token in its own path, not by who is asking, so a
signed-in owner following their own link gets exactly the page a
stranger gets. Which is what makes the control testable by the person
who turned it on.
No role column on links, deliberately. Ruling 15's other half is that
every write has a named actor, and a link holds no name; a role column
would be somewhere to later write "writer" into, and the answer to
whether an anonymous link may edit is already no (Paul's own Sheets
ruling 12). The column that cannot exist is the cheapest way to keep a
decision.
The token is 128 bits from crypto/rand, never derived from doc_id,
sqid, title or clock — a derived token is guessable by whoever reads
the migration. Publishing deletes any existing row and mints a fresh
one, so "stop publishing" is a revocation rather than a pause: a URL
kept from an earlier audience does not come back when the owner
publishes again for a different one.
publicPage carries a title, the HTML, and a notice. Not the sqid — that
is the private route's key, and printing it on a page anyone can read
hands a stranger the URL the signed-in app is organised around. The
struct is the list of things a stranger may know, so anything added to
it is a decision.
The share page's URL is built from cfg.Origin, not r.Host. r.Host is
attacker-controlled, and a link built from it is one an attacker aims
elsewhere by choosing a Host header. csrf.Protect already trusts
cfg.Origin for this reason.
Four mutations, and the fourth is the finding: making Publish return an
existing token SURVIVED. The revocation test only ever went off-then-on,
where the row is gone in between, so any implementation mints a new
token there and the rotation promise was untested.
TestPublishingAgainRotatesALiveLink publishes twice over a live link and
catches it. Same shape as the sed that silently did not match in the
previous commit: both were passing while proving nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RVcEQ8NgrPCpRTb6JtBz1Q
10 files changed,
+637
−1
internal/docs/app.go+8 −1internal/docs/handlers.go+13 −0internal/docs/links.go+150 −0internal/docs/migrations/0006_links.sql+37 −0internal/docs/publiclink_test.go+196 −0internal/docs/publish.go+113 −0internal/docs/render.go+31 −0internal/docs/share.go+29 −0internal/docs/views/public.html+25 −0internal/docs/views/share.html+35 −0
CI passed — run details
Intent
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 5835486..b74468e 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -410,7 +410,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()} |
| + h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin} |
| r := chi.NewRouter() |
| // Mounted above every route, per rastrillo SKILL.md §5: refuses a |
| @@ -436,6 +436,12 @@ func New(cfg Config) (*App, error) { |
| r.Post("/signin", gateBegin(authPlugin.Begin, list, "/signin")) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| + // The published document, and the only route here no session guards. |
| + // It is authorized by the token in its own path (links.go) rather |
| + // than by who is asking — which is what a capability is, and why it |
| + // sits above the RequireSession group rather than inside it with an |
| + // exception carved into the middleware. |
| + r.Get("/p/{token}", h.publicShow) |
| r.Post("/signout", authPlugin.Signout) |
| // staticHandler (assets.go) composes the committed editor bundle |
| // with render.assets.Handler()'s vendored design-system tree — |
| @@ -470,6 +476,7 @@ func New(cfg Config) (*App, error) { |
| r.Get("/d/{sqid}/share", h.shareGet) |
| r.Post("/d/{sqid}/share", h.sharePost) |
| r.Post("/d/{sqid}/share/revoke", h.shareRevoke) |
| + r.Post("/d/{sqid}/share/link", h.shareLink) |
| r.Get("/d/{sqid}/updates", h.updatesGet) |
| r.Post("/d/{sqid}/updates", h.updatesPost) |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index e920f1e..b0d4285 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -57,6 +57,19 @@ type handlers struct { |
| // exists at all. |
| streamHeartbeat time.Duration |
| streamTTL time.Duration |
| + |
| + // 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 |
| + // message: a relative path is not a thing you can paste to a person |
| + // who is not already here. |
| + // |
| + // The configured origin rather than r.Host, deliberately. r.Host is |
| + // attacker-controlled, and a link built from it is a link an |
| + // attacker can aim somewhere else by sending the owner a request |
| + // with a Host header of their choosing. csrf.Protect already trusts |
| + // cfg.Origin for exactly this reason; this trusts the same value. |
| + origin string |
| } |
| // listPage is GET /'s view data (spec §6.4). Title and the group |
diff --git a/internal/docs/links.go b/internal/docs/links.go| new file mode 100644 |
| index 0000000..3f86f9f |
| --- /dev/null |
| +++ b/internal/docs/links.go |
| @@ -0,0 +1,150 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "errors" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// Link is one document's read-only public capability: whoever holds the |
| +// token may read the document, signed in or not. |
| +// |
| +// The third rung of the suite doctrine's ruling 15, and the reason it has |
| +// 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"` |
| + CreatedAt time.Time `gorm:"column:created_at"` |
| +} |
| + |
| +// TableName pins the table rather than leaving it to GORM's pluraliser, |
| +// the same reason Grant does. |
| +func (Link) TableName() string { return "links" } |
| + |
| +// tokenBytes is 128 bits of entropy, which base64url renders as 22 |
| +// characters. The token is the entire authorization for a published |
| +// document, so this is the one number in this file worth arguing about: |
| +// 128 bits is the floor at which guessing stops being a strategy, and a |
| +// URL a person pastes into a message is not improved by being longer. |
| +const tokenBytes = 16 |
| + |
| +// newToken mints a capability. crypto/rand, never math/rand, and never |
| +// anything derived from the document — a token computed from doc_id, sqid, |
| +// title or a clock is guessable by whoever reads this file. |
| +// |
| +// rand.Read is documented never to return a short read without an error, |
| +// so an error here means the system has no entropy and the honest response |
| +// is to fail the request rather than publish under a weak token. |
| +func newToken() (string, error) { |
| + buf := make([]byte, tokenBytes) |
| + if _, err := rand.Read(buf); err != nil { |
| + return "", err |
| + } |
| + return base64.RawURLEncoding.EncodeToString(buf), nil |
| +} |
| + |
| +// Publish turns a document's public link on and returns it, minting a |
| +// fresh token every time. |
| +// |
| +// Fresh every time, deliberately: with Unpublish deleting the row, that |
| +// makes "off" a revocation rather than a pause. Somebody who kept a URL |
| +// from an earlier audience does not get back in when the owner publishes |
| +// again for a different one. |
| +// |
| +// 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) { |
| + doc, err := s.Get(ctx, owner, sqid) |
| + if err != nil { |
| + return nil, err |
| + } |
| + token, err := newToken() |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + link := Link{DocID: doc.ID, Token: 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 |
| + // reference to the row. Nothing does, and this way the intent reads |
| + // off the code. |
| + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.Where("doc_id = ?", doc.ID).Delete(&Link{}).Error; err != nil { |
| + return err |
| + } |
| + return tx.Create(&link).Error |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &link, nil |
| +} |
| + |
| +// Unpublish turns it off. Like Revoke, unpublishing something that is not |
| +// published succeeds: the owner asked for an outcome — that this document |
| +// is not readable by strangers — and that outcome holds either way. |
| +func (s *Store) Unpublish(ctx context.Context, owner int64, sqid string) error { |
| + doc, err := s.Get(ctx, owner, sqid) |
| + if err != nil { |
| + return err |
| + } |
| + 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. |
| +// 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) { |
| + 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 |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &link, nil |
| +} |
| + |
| +// ByToken resolves a public link to its document, for the one route in |
| +// this app that no session guards. |
| +// |
| +// An unknown token and a token whose document has been deleted answer |
| +// identically — gorm.ErrRecordNotFound, which the handler turns into the |
| +// same 404 a nonexistent URL gets. That is §4.2's rule reaching the public |
| +// route: without it, the difference between the two answers is an oracle |
| +// for which documents are published. |
| +// |
| +// The empty token is rejected before the query, so a request to a route |
| +// that somehow lost its path value cannot match a row with an empty |
| +// column. |
| +func (s *Store) ByToken(ctx context.Context, token string) (*Document, error) { |
| + if token == "" { |
| + return nil, gorm.ErrRecordNotFound |
| + } |
| + 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). |
| + First(&doc).Error |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &doc, nil |
| +} |
diff --git a/internal/docs/migrations/0006_links.sql b/internal/docs/migrations/0006_links.sql| new file mode 100644 |
| index 0000000..a117339 |
| --- /dev/null |
| +++ b/internal/docs/migrations/0006_links.sql |
| @@ -0,0 +1,37 @@ |
| +-- Publishing: a link is one document's read-only capability, held by whoever |
| +-- has the URL rather than by anybody the app can name. |
| +-- |
| +-- This is the third rung of the suite doctrine's ruling 15 — private, granted, |
| +-- and a read-only link — and the ruling's other half is why there is no role |
| +-- column here. Every write has a named actor; a link holds no name, so a link |
| +-- can only ever read. A `role` column would be a place for somebody to later |
| +-- write "writer" into, and the answer to "should an anonymous link be able to |
| +-- 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. |
| +-- |
| +-- 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 |
| +-- the row, and turning it back on mints a fresh token — which makes "off" a |
| +-- revocation rather than a pause. Somebody who kept a URL from last March does |
| +-- not get back in when the owner republishes for a different audience. |
| +-- |
| +-- No expiry column, deliberately: an unexpiring link the owner can see and |
| +-- switch off is honest, while an expiry nobody is reminded of is a promise the |
| +-- app makes on the owner's behalf and then keeps only by accident. |
| +CREATE TABLE IF NOT EXISTS links ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + doc_id INTEGER NOT NULL, |
| + token 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); |
diff --git a/internal/docs/publiclink_test.go b/internal/docs/publiclink_test.go| new file mode 100644 |
| index 0000000..65d46ae |
| --- /dev/null |
| +++ b/internal/docs/publiclink_test.go |
| @@ -0,0 +1,196 @@ |
| +package docs_test |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// anon is a request with no cookies at all — nobody, arriving from |
| +// somewhere else. The whole point of a public link is that this works, so |
| +// the helper exists to make every test below say "signed out" in one word. |
| +func anon(t *testing.T, app *docs.App, path string) *http.Response { |
| + t.Helper() |
| + rec := get(t, app, nil, path) |
| + 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. |
| +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() |
| + i := strings.Index(body, "/p/") |
| + if i < 0 { |
| + t.Fatal("the share page does not show the public link it just created") |
| + } |
| + rest := body[i:] |
| + end := strings.IndexAny(rest, `"'< `) |
| + if end < 0 { |
| + t.Fatal("could not read the public link off the share page") |
| + } |
| + return rest[:end] |
| +} |
| + |
| +// The feature, end to end: Paul's "being able to share read-only publicly". |
| +// A signed-out stranger with the link reads the document. |
| +func TestAPublicLinkLetsASignedOutStrangerRead(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + |
| + // Before the link exists, the app has no public door at all. |
| + if res := anon(t, app, "/d/"+sqid); res.StatusCode == http.StatusOK { |
| + t.Fatal("a signed-out request read a document through the private route") |
| + } |
| + |
| + path := linkFor(t, app, alice, sqid) |
| + res := anon(t, app, path) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("GET %s signed out: status %d, want 200", path, res.StatusCode) |
| + } |
| +} |
| + |
| +// Read-only means read-only, and the interesting half is that it holds |
| +// against the routes rather than only against the rendering. Ruling 15: |
| +// every write has a named actor, and a link holds no name. |
| +func TestAPublicLinkCarriesNoWrite(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + linkFor(t, app, alice, sqid) |
| + |
| + // Holding the link does not admit its holder to any authenticated |
| + // route: it is a capability on one document, not a session. |
| + for _, path := range []string{ |
| + "/", |
| + "/d/" + sqid, |
| + "/d/" + sqid + "/share", |
| + "/d/" + sqid + "/updates", |
| + } { |
| + if res := anon(t, app, path); res.StatusCode == http.StatusOK { |
| + t.Errorf("signed out, %s answered 200; a link is not a session", path) |
| + } |
| + } |
| + |
| + // And the public page offers no editor to drive one with. |
| + body := get(t, app, nil, linkFor(t, app, alice, sqid)).Body.String() |
| + for _, banned := range []string{"docs-editor", "editor-entry", "/updates"} { |
| + if strings.Contains(body, banned) { |
| + t.Errorf("the public page ships %q; it is a reading surface", banned) |
| + } |
| + } |
| +} |
| + |
| +// A wrong token is indistinguishable from a document that does not exist — |
| +// spec §4.2's rule, which the public route has to keep too or it becomes |
| +// an oracle for which documents are published. |
| +func TestAWrongTokenIsANotFound(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) |
| + |
| + 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) |
| + } |
| + } |
| +} |
| + |
| +// 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. |
| +func TestTurningThePublicLinkOffClosesIt(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) |
| + |
| + if res := anon(t, app, path); res.StatusCode != http.StatusOK { |
| + t.Fatalf("precondition: the link does not work (%d)", res.StatusCode) |
| + } |
| + if rec := post(t, app, alice, "/d/"+sqid+"/share/link", "on=0"); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST link off: status %d, want 303", rec.Code) |
| + } |
| + if res := anon(t, app, path); res.StatusCode == http.StatusOK { |
| + t.Error("the link still reads the document after being turned off") |
| + } |
| + |
| + // Turning it back on mints a NEW token: the old one stays dead, so |
| + // "off" is a revocation rather than a pause somebody can undo for a |
| + // stranger who kept the URL. |
| + again := linkFor(t, app, alice, sqid) |
| + if again == path { |
| + t.Error("re-enabling reissued the same token; the revoked link came back") |
| + } |
| + if res := anon(t, app, path); res.StatusCode == http.StatusOK { |
| + t.Error("the old token works again after the link was re-enabled") |
| + } |
| +} |
| + |
| +// Publishing over a live link rotates it. This is the property the store |
| +// promises ("a fresh token every time") and the one the off/on test above |
| +// cannot see: with the row deleted in between, any implementation mints a |
| +// new token, so that test passes on a Publish that reuses an existing one. |
| +// A mutation that made Publish return the existing row survived it, which |
| +// is why this test exists. |
| +// |
| +// It is the rotation an owner needs when a link has gone somewhere they |
| +// did not intend: republish, and the copy that escaped stops working. |
| +func TestPublishingAgainRotatesALiveLink(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + alice := signInCookies(t, app, "alice@example.com") |
| + sqid := createDoc(t, app, alice, "Minutes") |
| + |
| + first := linkFor(t, app, alice, sqid) |
| + second := linkFor(t, app, alice, sqid) // no Unpublish in between |
| + if second == first { |
| + t.Fatal("publishing over a live link reissued the same token") |
| + } |
| + if res := anon(t, app, first); res.StatusCode == http.StatusOK { |
| + t.Error("the rotated-away token still reads the document") |
| + } |
| + if res := anon(t, app, second); res.StatusCode != http.StatusOK { |
| + t.Errorf("the new token does not work: status %d", res.StatusCode) |
| + } |
| +} |
| + |
| +// Only the owner controls it. A writer grantee cannot publish somebody |
| +// else's document to the whole internet — the same rule that stops them |
| +// re-sharing it to one person, and it matters far more here. |
| +func TestOnlyTheOwnerMayPublish(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") |
| + |
| + 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) |
| + } |
| +} |
| + |
| +// The public page must not carry who else can reach the document. The |
| +// share list is the owner's alone, and a published page is the widest |
| +// audience this app has. |
| +func TestThePublicPageLeaksNoAudience(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + 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") |
| + path := linkFor(t, app, alice, sqid) |
| + |
| + body := get(t, app, nil, path).Body.String() |
| + for _, secret := range []string{"alice@example.com", "bob@example.com", sqid} { |
| + if strings.Contains(body, secret) { |
| + t.Errorf("the public page names %q", secret) |
| + } |
| + } |
| +} |
diff --git a/internal/docs/publish.go b/internal/docs/publish.go| new file mode 100644 |
| index 0000000..9a3e2dc |
| --- /dev/null |
| +++ b/internal/docs/publish.go |
| @@ -0,0 +1,113 @@ |
| +package docs |
| + |
| +import ( |
| + "errors" |
| + "html/template" |
| + "net/http" |
| + |
| + "github.com/carlosframework/rastrillo" |
| + "gorm.io/gorm" |
| +) |
| + |
| +// publicPage is GET /p/{token}'s view data: a document, and nothing about |
| +// who can reach it. |
| +// |
| +// Compare showPage, which carries Sqid, an editor script, save-status copy |
| +// and a share control. None of that is here, and the absence is the |
| +// feature — this struct is the list of things a stranger is allowed to |
| +// know, so anything added to it is a decision rather than an oversight. |
| +// |
| +// Sqid in particular is absent on purpose. It is the private route's key, |
| +// and printing it on a page anyone can read would hand a stranger the URL |
| +// the signed-in app is organised around. |
| +type publicPage struct { |
| + Title string |
| + HTML template.HTML |
| + |
| + // ReadOnly is the standing notice that this is a published copy. It |
| + // is authored copy rather than a rendering decision, so it goes |
| + // through the catalog like every other string here. |
| + ReadOnly string |
| +} |
| + |
| +// publicShow serves GET /p/{token}, the one route in this app that no |
| +// session guards. |
| +// |
| +// It resolves the token to a document and renders it. There is no branch |
| +// on who is asking, because a capability does not have a who: a signed-in |
| +// owner following their own link gets the same page a stranger does, which |
| +// is what makes the control testable by the person who turned it on. |
| +// |
| +// Every failure is 404 (§4.2 reaching the public route): an unknown token, |
| +// a revoked one, and a token whose document was deleted are one answer, or |
| +// the differences between them tell a stranger which documents exist and |
| +// which were published and then withdrawn. |
| +func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) { |
| + doc, err := h.store.ByToken(r.Context(), r.PathValue("token")) |
| + if err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + h.logger.Error("docs: resolve public link", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + html, err := h.store.HTML(r.Context(), doc) |
| + if err != nil { |
| + h.logger.Error("docs: public document html", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + 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. |
| + if err := h.render.page(w, r, "public", publicPage{ |
| + Title: doc.Title, |
| + HTML: html, |
| + ReadOnly: rastrillo.T(r, "docs.public.readonly"), |
| + }); err != nil { |
| + h.logger.Error("docs: render public document", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + } |
| +} |
| + |
| +// shareLink serves POST /d/{sqid}/share/link: the owner's switch. |
| +// |
| +// One route for both directions rather than two, because the form is a |
| +// switch and its current state decides which way it points — two routes |
| +// would let a stale page turn something on that the owner had just turned |
| +// off, from a button that still said "off". |
| +// |
| +// Owner-only through Publish/Unpublish's own Get. A writer grantee is |
| +// 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) { |
| + owner, err := h.owner(r) |
| + if err != nil { |
| + h.logger.Error("docs: resolve owner for publish", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + sqid := r.PathValue("sqid") |
| + |
| + on := r.PostFormValue("on") == "1" |
| + if on { |
| + _, err = h.store.Publish(r.Context(), owner, sqid) |
| + } else { |
| + err = h.store.Unpublish(r.Context(), owner, sqid) |
| + } |
| + switch { |
| + case err == nil: |
| + http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther) |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + http.NotFound(w, r) |
| + default: |
| + h.logger.Error("docs: set public link", "sqid", sqid, "on", on, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + } |
| +} |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 2f1f46c..fc96104 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -74,6 +74,36 @@ var Catalog = rastrillo.Catalog{ |
| "docs.share.empty": "Only you can open this document.", |
| "docs.share.error.address": "Type the email address of the person you want to share with.", |
| + // docs.share.public.* is the third rung — a read-only link anyone |
| + // can follow, signed in or not (suite doctrine ruling 15). |
| + // |
| + // The body says "anyone with the link", not "the public": the |
| + // distinction is the whole security model of a capability, and an |
| + // owner who reads it as "listed somewhere" or as "safe because it is |
| + // unguessable" is being told the wrong thing either way. It also |
| + // says read, twice over with the heading, because the one question |
| + // an owner has about this switch is whether a stranger can change |
| + // their document. |
| + // |
| + // The off label is "Stop publishing" rather than "Turn off": what it |
| + // does is kill the URL, and a person who has handed that URL to |
| + // somebody deserves a verb that says so. It pairs with |
| + // docs.share.revoke's "Stop sharing" by design — one vocabulary for |
| + // taking access away, whichever rung it was granted on. |
| + "docs.share.public.heading": "Anyone with a link", |
| + "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.public.readonly is the standing notice on the published page |
| + // itself (views/public.html). It is there because a person arriving |
| + // from a pasted link has no other way to know they are looking at a |
| + // copy they cannot edit — the page has no editor to try, so without |
| + // this the absence reads as a page that failed to load its own |
| + // controls. |
| + "docs.public.readonly": "You are reading a shared copy of this document.", |
| + |
| "docs.list.title": "Documents", |
| "docs.list.group.today": "Today", |
| "docs.list.group.yesterday": "Yesterday", |
| @@ -405,6 +435,7 @@ const headTemplate = "views/head.html" |
| // pageTemplates maps a page name to its file under views/. |
| var pageTemplates = map[string]string{ |
| "list": "views/list.html", |
| + "public": "views/public.html", |
| "share": "views/share.html", |
| "show": "views/show.html", |
| "signin": "views/signin.html", |
diff --git a/internal/docs/share.go b/internal/docs/share.go| index 8bc5f98..a4d8a93 100644 |
| --- a/internal/docs/share.go |
| +++ b/internal/docs/share.go |
| @@ -25,6 +25,17 @@ type sharePage struct { |
| Error string |
| Grants []shareRow |
| SignOutLabel string |
| + |
| + // The public link, the third rung of ruling 15. PublicURL is empty |
| + // when the document is not published, and that emptiness is the |
| + // switch's state — one field rather than a bool beside a string that |
| + // could disagree with it. |
| + PublicHeading string |
| + PublicBody string |
| + PublicURL string |
| + PublishButton string |
| + UnpublishBtn string |
| + CopyHint string |
| } |
| // shareRow is one person who reaches this document. |
| @@ -65,6 +76,17 @@ 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 |
| + // 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 |
| + } |
| + |
| rows := make([]shareRow, 0, len(grants)) |
| for _, g := range grants { |
| label := "docs.share.role.reader" |
| @@ -89,6 +111,13 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu |
| EmptyBody: rastrillo.T(r, "docs.share.empty"), |
| Grants: rows, |
| SignOutLabel: rastrillo.T(r, "docs.signout.label"), |
| + |
| + PublicHeading: rastrillo.T(r, "docs.share.public.heading"), |
| + PublicBody: rastrillo.T(r, "docs.share.public.body"), |
| + PublicURL: publicURL, |
| + PublishButton: rastrillo.T(r, "docs.share.public.on"), |
| + UnpublishBtn: rastrillo.T(r, "docs.share.public.off"), |
| + CopyHint: rastrillo.T(r, "docs.share.public.hint"), |
| } |
| if errKey != "" { |
| page.Error = rastrillo.T(r, errKey) |
diff --git a/internal/docs/views/public.html b/internal/docs/views/public.html| new file mode 100644 |
| index 0000000..2d28f4d |
| --- /dev/null |
| +++ b/internal/docs/views/public.html |
| @@ -0,0 +1,25 @@ |
| +{{/* The published document: the one screen in this app a stranger sees. |
| + |
| + No "account" block is defined, and that is deliberate rather than an |
| + omission. tokens.css hides a disclosure whose menu is empty, so |
| + leaving it undefined removes the account dropdown — and a signed-out |
| + reader must not be offered a sign-out control for a session they do |
| + not have. render.go's own comment on shellLayout records that this is |
| + how a screen opts out of it. |
| + |
| + No "nav" block either: the share control lives there on the private |
| + page, and this reader owns nothing to share. |
| + |
| + No editor. Not disabled, not hidden — absent. A read-only surface |
| + built by hiding an editor is one CSS rule away from being an editor |
| + again, and the updates route it would post to is behind the session |
| + guard anyway, so the mount point would only produce a page that |
| + fails at the network. Ruling 15's other half: every write has a |
| + named actor, and whoever is reading this has no name. */}} |
| +{{define "brand"}}<a rst-shell-brand href="/">{{.Title}}</a>{{end}} |
| +{{define "content"}} |
| +<article rst-stack docs-public> |
| + <p rst-notice docs-public-notice>{{.ReadOnly}}</p> |
| + <div docs-public-body>{{.HTML}}</div> |
| +</article> |
| +{{end}} |
diff --git a/internal/docs/views/share.html b/internal/docs/views/share.html| index d8d9838..2a42a32 100644 |
| --- a/internal/docs/views/share.html |
| +++ b/internal/docs/views/share.html |
| @@ -64,5 +64,40 @@ |
| <button type="submit">{{.ShareButton}}</button> |
| </form> |
| + |
| + {{/* The third rung: a read-only link anyone can follow, signed in or |
| + not. One <form> whichever way the switch currently points, with |
| + the direction in a hidden field — so a page left open in a tab |
| + cannot turn publishing back on from a button that still says |
| + "off", which two separate routes would have allowed. |
| + |
| + The URL is printed as text, not as a link. It is meant to be |
| + copied into a message rather than followed from here, and a |
| + page whose only affordance is a link invites the owner to click |
| + it, land on their own published copy, and wonder where the app |
| + went. Reading it as text also makes it obvious that this is a |
| + thing you hand to someone. |
| + |
| + No copy button: that needs JavaScript, and this screen has a |
| + zero-JS floor (spec §6.2). Select-and-copy works everywhere, |
| + today. */}} |
| + <section docs-share-public> |
| + <h2>{{.PublicHeading}}</h2> |
| + <p rst-muted>{{.PublicBody}}</p> |
| + |
| + {{if .PublicURL}} |
| + <p docs-share-url><code>{{.PublicURL}}</code></p> |
| + <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> |
| + {{else}} |
| + <form method="post" action="/d/{{.Sqid}}/share/link"> |
| + <input type="hidden" name="on" value="1"> |
| + <button type="submit">{{.PublishButton}}</button> |
| + </form> |
| + {{end}} |
| + </section> |
| </article> |
| {{end}} |