| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "html/template" |
| 6 | "net/http" |
| 7 | |
| 8 | "amadan.net/rastrillo/rastrillo" |
| 9 | "gorm.io/gorm" |
| 10 | ) |
| 11 | |
| 12 | // publicPage is GET /p/{token}'s view data: a document, and nothing about |
| 13 | // who can reach it. |
| 14 | // |
| 15 | // Compare showPage, which carries Sqid, an editor script, save-status copy |
| 16 | // and a share control. None of that is here, and the absence is the |
| 17 | // feature — this struct is the list of things a stranger is allowed to |
| 18 | // know, so anything added to it is a decision rather than an oversight. |
| 19 | // |
| 20 | // Sqid in particular is absent on purpose. It is the private route's key, |
| 21 | // and printing it on a page anyone can read would hand a stranger the URL |
| 22 | // the signed-in app is organised around. |
| 23 | type publicPage struct { |
| 24 | Frame Frame |
| 25 | Title string |
| 26 | HTML template.HTML |
| 27 | |
| 28 | // ReadOnly is the standing notice that this is a published copy. It |
| 29 | // is authored copy rather than a rendering decision, so it goes |
| 30 | // through the catalog like every other string here. |
| 31 | ReadOnly string |
| 32 | } |
| 33 | |
| 34 | // publicShow serves GET /p/{token}, the one route in this app that no |
| 35 | // session guards. |
| 36 | // |
| 37 | // It resolves the token to a document and renders it. There is no branch |
| 38 | // on who is asking, because a capability does not have a who: a signed-in |
| 39 | // owner following their own link gets the same page a stranger does, which |
| 40 | // is what makes the control testable by the person who turned it on. |
| 41 | // |
| 42 | // Every failure is 404 (§4.2 reaching the public route): an unknown token, |
| 43 | // a revoked one, and a token whose document was deleted are one answer, or |
| 44 | // the differences between them tell a stranger which documents exist and |
| 45 | // which were published and then withdrawn. |
| 46 | func (h *handlers) publicShow(w http.ResponseWriter, r *http.Request) { |
| 47 | doc, err := h.store.ByToken(r.Context(), r.PathValue("token")) |
| 48 | if err != nil { |
| 49 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 50 | h.fail(w, r, http.StatusNotFound, failDocument) |
| 51 | return |
| 52 | } |
| 53 | h.logger.Error("docs: resolve public link", "err", err) |
| 54 | h.fail(w, r, h.internalFailureStatus(), failStatus) |
| 55 | return |
| 56 | } |
| 57 | |
| 58 | html, err := h.store.HTML(r.Context(), doc) |
| 59 | if err != nil { |
| 60 | h.logger.Error("docs: public document html", "err", err) |
| 61 | h.fail(w, r, h.internalFailureStatus(), failStatus) |
| 62 | return |
| 63 | } |
| 64 | |
| 65 | // no-store, said rather than implied. The first version of this |
| 66 | // handler set no header at all and its comment claimed that kept a |
| 67 | // shared cache from retaining a revoked copy — which is not what an |
| 68 | // absent Cache-Control means. Absent leaves it to heuristics; a |
| 69 | // revoked link's page could sit in an intermediary. stream.go and |
| 70 | // search.go already say no-store for exactly this reason. |
| 71 | // |
| 72 | // X-Robots-Tag because "anyone with the link" is not "anyone": a |
| 73 | // capability that leaks into a crawlable place should not also become |
| 74 | // a search result. It is not a security control — the link is the |
| 75 | // authorization and a crawler that has it can read the page — it is a |
| 76 | // refusal to help the leak spread. |
| 77 | w.Header().Set("Cache-Control", "no-store") |
| 78 | w.Header().Set("X-Robots-Tag", "noindex, nofollow") |
| 79 | if err := h.render.page(w, r, "public", publicPage{ |
| 80 | Frame: h.frame(r, frameOptions{titleParts: []string{doc.Title}, context: doc.Title, public: true}), |
| 81 | Title: doc.Title, |
| 82 | HTML: html, |
| 83 | ReadOnly: rastrillo.T(r, "docs.public.readonly"), |
| 84 | }); err != nil { |
| 85 | h.logger.Error("docs: render public document", "err", err) |
| 86 | h.fail(w, r, h.internalFailureStatus(), failStatus) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // shareLink serves POST /d/{sqid}/share/link: the owner's switch. |
| 91 | // |
| 92 | // One route for both directions rather than two, because the form is a |
| 93 | // switch and its current state decides which way it points — two routes |
| 94 | // would let a stale page turn something on that the owner had just turned |
| 95 | // off, from a button that still said "off". |
| 96 | // |
| 97 | // Owner-only through Publish/Unpublish's own Get. A writer grantee is |
| 98 | // answered 404: publishing to everyone is the largest re-share there is, |
| 99 | // and no grant carries re-sharing. |
| 100 | func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) { |
| 101 | // Member-only, as the other two share routes are. Publishing to the |
| 102 | // whole internet is the largest re-share there is. |
| 103 | if !h.requireMemberRequest(w, r, true) { |
| 104 | return |
| 105 | } |
| 106 | owner, err := h.owner(r) |
| 107 | if err != nil { |
| 108 | h.logger.Error("docs: resolve owner for publish", "err", err) |
| 109 | h.fail(w, r, h.internalFailureStatus(), failStatus) |
| 110 | return |
| 111 | } |
| 112 | sqid := r.PathValue("sqid") |
| 113 | |
| 114 | on := r.PostFormValue("on") == "1" |
| 115 | if on { |
| 116 | var token string |
| 117 | token, err = h.store.Publish(r.Context(), owner, sqid) |
| 118 | if err == nil { |
| 119 | // The only moment this URL exists outside the token itself. |
| 120 | // Parked for exactly one render (oneshot.go) and never |
| 121 | // written down, because the database holds only a hash. |
| 122 | h.oneShots.put(oneShotKey(owner, sqid), h.origin+"/p/"+token) |
| 123 | } |
| 124 | } else { |
| 125 | err = h.store.Unpublish(r.Context(), owner, sqid) |
| 126 | } |
| 127 | switch { |
| 128 | case err == nil: |
| 129 | if wantsNative(r) { |
| 130 | nativeJSON(w, r, 200, map[string]any{"published": on, "public_url": h.oneShots.take(oneShotKey(owner, sqid))}) |
| 131 | return |
| 132 | } |
| 133 | http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther) |
| 134 | case errors.Is(err, gorm.ErrRecordNotFound): |
| 135 | h.fail(w, r, http.StatusNotFound, failDocument) |
| 136 | default: |
| 137 | h.logger.Error("docs: set public link", "sqid", sqid, "on", on, "err", err) |
| 138 | h.fail(w, r, h.internalFailureStatus(), failStatus) |
| 139 | } |
| 140 | } |
| 141 | |