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
3// The two export routes, and the view data the formatting bar and the
4// download links need.
5//
6// Both routes are GETs of a document the signed-in person already
7// owns, resolved through the same getDocumentForUpdates every other
8// document route uses — so a document belonging to someone else 404s
9// identically to one that does not exist (spec §4.2), and there is no
10// separate ownership check here that could 403 by mistake.
11
12import (
13 "fmt"
14 "net/http"
15 "strconv"
16 "strings"
17 "unicode"
18
19 "amadan.net/rastrillo/rastrillo"
20
21 "github.com/carlosframework/docs/internal/export"
22 "github.com/carlosframework/docs/internal/fonts"
23)
24
25// toolbarOption is one <option> in a toolbar select.
26type toolbarOption struct {
27 Value string
28 Label string
29}
30
31// toolbarView is the formatting bar's copy, all of it resolved from
32// the catalog at render time. fixtures/toolbar.mjs reads the values
33// (data-docs-command, and each option's value) and never the labels —
34// the bundle carries no copy of its own, the same rule the save-status
35// indicator follows.
36type toolbarView struct {
37 Label string
38 Undo string
39 Redo string
40 Bold string
41 Italic string
42 Code string
43 BulletList string
44 OrderedList string
45 Quote string
46 AlignLeft string
47 AlignCenter string
48 AlignRight string
49
50 BlockLabel string
51 Blocks []toolbarOption
52 FontLabel string
53 Fonts []toolbarOption
54 SizeLabel string
55 Sizes []toolbarOption
56
57 // The three strings ui/select.js reads off data attributes when it
58 // turns a <select> into a filterable combobox. See newToolbarView
59 // for why they come from the framework's catalog rather than this
60 // app's.
61 SelectFilter string
62 SelectResults string
63 SelectResultOne string
64}
65
66// newToolbarView builds it for one request's locale.
67//
68// The option values are the vocabulary shared with
69// fixtures/toolbar.mjs ("paragraph", "heading-2", "sans", "lg") and,
70// through it, with core/src/fragment.rs's mark names. They are
71// literals here rather than derived from anything, because they are a
72// contract with two other files and a derivation would hide that.
73func newToolbarView(r *http.Request) toolbarView {
74 return toolbarView{
75 Label: rastrillo.T(r, "docs.editor.toolbar.label"),
76 Undo: rastrillo.T(r, "docs.editor.toolbar.undo"),
77 Redo: rastrillo.T(r, "docs.editor.toolbar.redo"),
78 Bold: rastrillo.T(r, "docs.editor.toolbar.bold"),
79 Italic: rastrillo.T(r, "docs.editor.toolbar.italic"),
80 Code: rastrillo.T(r, "docs.editor.toolbar.code"),
81 BulletList: rastrillo.T(r, "docs.editor.toolbar.bulletlist"),
82 OrderedList: rastrillo.T(r, "docs.editor.toolbar.orderedlist"),
83 Quote: rastrillo.T(r, "docs.editor.toolbar.quote"),
84 // The command names these three buttons carry ("align_left"
85 // and so on) are a contract with fixtures/toolbar.mjs, and
86 // through it with core/src/fragment.rs's ALIGNMENTS table —
87 // the suffix after "align_" is the attribute value that ends
88 // up in the CRDT.
89 AlignLeft: rastrillo.T(r, "docs.editor.toolbar.align.left"),
90 AlignCenter: rastrillo.T(r, "docs.editor.toolbar.align.center"),
91 AlignRight: rastrillo.T(r, "docs.editor.toolbar.align.right"),
92
93 BlockLabel: rastrillo.T(r, "docs.editor.toolbar.block"),
94 Blocks: []toolbarOption{
95 {"paragraph", rastrillo.T(r, "docs.editor.toolbar.block.paragraph")},
96 {"heading-1", rastrillo.T(r, "docs.editor.toolbar.block.heading1")},
97 {"heading-2", rastrillo.T(r, "docs.editor.toolbar.block.heading2")},
98 {"heading-3", rastrillo.T(r, "docs.editor.toolbar.block.heading3")},
99 },
100 FontLabel: rastrillo.T(r, "docs.editor.toolbar.font"),
101 // Sans first, for the reason spelled out under Sizes below: a
102 // <select> shows its first option until something sets a value,
103 // and with no JavaScript nothing ever does. This list used to
104 // open with Serif and was right to, because serif was the face
105 // an unmarked run was in; it is sans now (internal/fonts'
106 // Default), and the order had to move with it or the control
107 // would name a face the document is not in. The order is the
108 // same one fixtures/fonts.mjs's FONTS carries, and
109 // TestTheToolbarDefaultFontIsTheDefaultFace holds both to
110 // internal/fonts.
111 Fonts: []toolbarOption{
112 {"sans", rastrillo.T(r, "docs.editor.toolbar.font.sans")},
113 {"serif", rastrillo.T(r, "docs.editor.toolbar.font.serif")},
114 {"mono", rastrillo.T(r, "docs.editor.toolbar.font.mono")},
115 },
116 SizeLabel: rastrillo.T(r, "docs.editor.toolbar.size"),
117 Sizes: sizeOptions(),
118
119 // The searchable-select enhancement's own strings.
120 //
121 // These are the FRAMEWORK's catalog keys, not this app's — the
122 // only place in this file that reaches into rastrillo's own
123 // namespace, and deliberately so. ui/select.js builds its
124 // combobox in the browser, where the catalog is out of reach, so
125 // the strings ride out on data attributes;
126 // ui/partials/field-select.html emits exactly these three keys
127 // for exactly this reason, and our hand-written selects have to
128 // do the same rather than invent app-side copy for a framework
129 // control. Getting them from rastrillo.T means they follow the
130 // reader's locale like everything else, in twelve base locales
131 // we do not have to translate ourselves.
132 SelectFilter: rastrillo.T(r, "rastrillo.ui.select_filter"),
133 SelectResults: rastrillo.T(r, "rastrillo.ui.select_results"),
134 SelectResultOne: rastrillo.T(r, "rastrillo.ui.select_result_one"),
135 }
136}
137
138// sizeOptions is the point ladder the size control offers.
139//
140// THE LABELS ARE NOT CATALOG KEYS, and that is deliberate rather than
141// an oversight of AGENTS.md's "every user-visible string is a catalog
142// key". A point size is a quantity, not authored copy — the same
143// category as a document's title, which views/show.html also prints
144// as-is rather than resolving. "12" is not English, there is nothing to
145// translate, and eleven catalog entries mapping "12" to "12" in every
146// locale would be eleven more things to keep in step for no reader's
147// benefit.
148//
149// The four named steps this replaced — Small, Normal, Large, Extra
150// large — genuinely were copy, and their keys are still in the catalog
151// because documents predating the ladder still carry those marks. They
152// are simply not offered here any more.
153//
154// DEFAULT FIRST, which is why this is written out rather than sorted. A
155// <select> shows its first option until something sets a value, and
156// with no JavaScript nothing ever does — export.go's own history has
157// this exact bug, with Small listed first over body text that was
158// Normal. fixtures/sizes.mjs's SIZES carries the same order and its
159// module-load guard refuses any other, so the two cannot drift.
160func sizeOptions() []toolbarOption {
161 // The same ladder as fixtures/sizes.mjs's SIZES, default first.
162 // internal/docs's TestSizeLadderMatchesTheBundle holds them equal —
163 // Go and Node cannot import each other, so a test is the only thing
164 // that can.
165 points := []int{12, 8, 9, 10, 11, 14, 18, 24, 36, 48, 72}
166 out := make([]toolbarOption, 0, len(points))
167 for _, pt := range points {
168 v := strconv.Itoa(pt)
169 out = append(out, toolbarOption{v, v})
170 }
171 return out
172}
173
174// fontFace is one @font-face rule show.html renders. The path is
175// content-hashed by internal/fonts, so the stylesheet can never name a
176// file this binary does not serve, and the year-long cache header on
177// it is safe.
178type fontFace struct {
179 CSSName string
180 Path string
181 Weight string
182 Style string
183}
184
185// fontFaces is the whole core set, in internal/fonts' own stable
186// order. All twelve are declared on every document page: a browser
187// fetches only the faces the page's text actually uses, so declaring
188// one nobody has applied costs a reader nothing, and declaring them
189// conditionally would mean the stylesheet depending on the document's
190// content.
191func fontFaces() []fontFace {
192 all := fonts.All()
193 out := make([]fontFace, 0, len(all))
194 for _, f := range all {
195 weight := "400"
196 if f.Bold {
197 weight = "700"
198 }
199 style := "normal"
200 if f.Italic {
201 style = "italic"
202 }
203 out = append(out, fontFace{
204 CSSName: f.Family.CSSName(),
205 Path: f.Path,
206 Weight: weight,
207 Style: style,
208 })
209 }
210 return out
211}
212
213// exportMarkdown serves GET /d/{sqid}/export.md.
214func (h *handlers) exportMarkdown(w http.ResponseWriter, r *http.Request) {
215 doc, structure, ok := h.documentForExport(w, r)
216 if !ok {
217 return
218 }
219 body := export.Markdown(structure)
220
221 // text/markdown is the registered type (RFC 7763). charset is
222 // stated because the export is UTF-8 and a reader that guesses
223 // latin-1 turns every em dash into mojibake.
224 w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
225 setDownloadName(w, doc, ".md")
226 if err := writeGuardedBody(w, r, 0, []byte(body)); err != nil {
227 h.logger.Error("docs: write markdown export", "sqid", doc.Sqid, "err", err)
228 }
229}
230
231// exportPDF serves GET /d/{sqid}/export.pdf.
232func (h *handlers) exportPDF(w http.ResponseWriter, r *http.Request) {
233 doc, structure, ok := h.documentForExport(w, r)
234 if !ok {
235 return
236 }
237
238 body, report, err := export.PDF(structure)
239 if err != nil {
240 h.logger.Error("docs: render pdf export", "sqid", doc.Sqid, "err", err)
241 h.fail(w, r, h.internalFailureStatus(), failStatus)
242 return
243 }
244 if !report.Empty() {
245 // §11.2's degrade-loudly rule, as loudly as this route can
246 // currently manage: the PDF is still served — a document with
247 // one unsettable character is far more useful than an error —
248 // but the substitution is on the record rather than only in
249 // the file. Surfacing it to the person who asked needs a
250 // screen to surface it on, which is the import/export report
251 // §11.2 describes and this branch does not build.
252 //
253 // The COUNT and the Unicode blocks, never the characters
254 // themselves. Report.String() renders the actual runes, and
255 // this log line reaches the platform's own log store
256 // (`carlos logs`) — spec §2.1's leak list, item 5, is
257 // categorical that a handler must never interpolate document
258 // content into a log or an error. For a short document in an
259 // unshipped script the deduplicated set of its characters
260 // approximates the document, so this would have leaked most of
261 // one in plaintext, out of a system whose whole premise is
262 // that documents are sealed at rest — and it would have done
263 // it to exactly the people the missing fonts already
264 // disadvantage. Blocks are enough to act on: they say which
265 // faces to add.
266 h.logger.Warn("docs: pdf export substituted characters",
267 "sqid", doc.Sqid,
268 "count", len(report.MissingRunes),
269 "blocks", report.Blocks())
270 }
271
272 w.Header().Set("Content-Type", "application/pdf")
273 setDownloadName(w, doc, ".pdf")
274 if err := writeGuardedBody(w, r, 0, body); err != nil {
275 h.logger.Error("docs: write pdf export", "sqid", doc.Sqid, "err", err)
276 }
277}
278
279// documentForExport resolves the document and reads its structure out
280// of the CRDT core. ok is false once a response has already been
281// written.
282func (h *handlers) documentForExport(w http.ResponseWriter, r *http.Request) (*Document, export.Document, bool) {
283 doc, ok := h.getDocumentForUpdates(w, r)
284 if !ok {
285 return nil, export.Document{}, false
286 }
287
288 structureJSON, err := h.store.JSON(r.Context(), doc)
289 if err != nil {
290 h.logger.Error("docs: document structure", "sqid", doc.Sqid, "err", err)
291 h.fail(w, r, h.internalFailureStatus(), failStatus)
292 return nil, export.Document{}, false
293 }
294
295 structure, err := export.Parse(doc.Title, structureJSON)
296 if err != nil {
297 h.logger.Error("docs: parse document structure", "sqid", doc.Sqid, "err", err)
298 h.fail(w, r, h.internalFailureStatus(), failStatus)
299 return nil, export.Document{}, false
300 }
301 return doc, structure, true
302}
303
304// setDownloadName writes the Content-Disposition header.
305//
306// The filename comes from the document's title, which is arbitrary
307// text somebody typed — including, potentially, quotes, semicolons,
308// newlines and path separators, every one of which means something in
309// this header or in a filesystem. exportFilename below reduces it to a
310// conservative set rather than escaping it, because a filename is not
311// the place to be clever, and falls back to the sqid when nothing
312// usable survives.
313func setDownloadName(w http.ResponseWriter, doc *Document, ext string) {
314 w.Header().Set("Content-Disposition",
315 fmt.Sprintf("attachment; filename=%q", exportFilename(doc, ext)))
316}
317
318// exportFilename builds a safe download name.
319//
320// Deliberately ASCII-only, and deliberately not RFC 5987's filename*
321// form: a person exporting a document called "Café notes" gets
322// "Caf-notes.pdf", which is a slightly worse name and no risk at all,
323// where the alternative is two encodings of the same header that
324// browsers have historically disagreed about. The document's real
325// title is inside the file either way — the PDF carries it as
326// metadata and the Markdown as its first heading.
327func exportFilename(doc *Document, ext string) string {
328 var b strings.Builder
329 lastDash := true // suppress a leading dash
330 for _, r := range doc.Title {
331 switch {
332 case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
333 b.WriteRune(r)
334 lastDash = false
335 case !lastDash:
336 // Every other character — spaces, punctuation, anything
337 // non-ASCII — collapses to a single dash.
338 b.WriteByte('-')
339 lastDash = true
340 }
341 if b.Len() >= 60 {
342 break
343 }
344 }
345 name := strings.Trim(b.String(), "-")
346 if name == "" {
347 // A title of nothing but punctuation, or a document called
348 // "日報". The sqid is always a usable filename by construction
349 // (store.go's sqidAlphabet is lowercase letters and digits).
350 name = doc.Sqid
351 }
352 return name + ext
353}
354