oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
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.
23type 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.
46func (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.
100func (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