| 1 | package 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 | |
| 12 | import ( |
| 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. |
| 26 | type 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. |
| 36 | type 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. |
| 73 | func 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. |
| 160 | func 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. |
| 178 | type 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. |
| 191 | func 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. |
| 214 | func (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. |
| 232 | func (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. |
| 282 | func (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. |
| 313 | func 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. |
| 327 | func 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 | |