A security review of the whole repo. The queries were already scoped, the
OIDC flow already did state and nonce and PKCE, the session tokens were
already stored as hashes. What it found was mostly the seam between the
code and the deployment — and one place where the deployment quietly
undid the code.
The one that matters: with any AUTHENTIK_* variable missing, Petal fell
back to resolving every request to the single `local` user. That is right
on a laptop and a catastrophe on a public host, and Phase 16 removed the
Traefik basic-auth gate that used to stand behind the mistake. A typo in
the client secret would have served her journals to the open internet and
said so only in a log line nobody reads. It now refuses to start, guarded
by default for any BASE_URL that isn't loopback.
Then the one that would have been fixed and wasn't: stored images now
serve under `default-src 'none'; sandbox`, so an SVG pasted into a
document can't run as a page on Petal's own origin. Traefik's
customresponseheaders *overwrites*, so the CSP declared in the compose
labels would have silently replaced that per-route policy in production.
The whole header block moved into the binary, where a route can tighten
its own and a test can prove it; only HSTS stays at the edge, where TLS
actually terminates.
The rest, smaller:
- PETAL_ALLOWED_SUBS empty means everyone authentik authenticates, and
authentik here fronts half a dozen applications. Still legal, now
said out loud every boot, and set in both env examples.
- LLM failures relayed err.Error() to the browser, which carries the
address of the inference box on the far side of the VPN. Logged
instead; the client only ever rendered "the helper is resting".
- Exports scheme-check their links. Escaping makes a URL safe to sit
in an attribute and says nothing about following it, and an export
is the one artifact here meant to leave. Writing the test found the
markdown image src, which I'd missed reading it.
- The draft rescue is namespaced per account and cleared on sign-out.
Everything else in localStorage is a preference; this is her unsaved
writing, sitting in a profile two people share.
- /auth/logout is POST-only. With SameSite=Lax a GET route lets any
page on the internet sign her out mid-draft.
- Image uploads get a per-account allowance and the TTS cache a size
cap. Both share the encrypted volume the database is on, and a full
disk is SQLite failing to write, not a feature degrading.
- The session cookie takes the __Host- prefix over https, so nothing
else under parodia.dev can plant one. Old cookies still resolve;
nobody is signed out to get there.
- npm audit: linkify-it and postcss.
Verified: go build, go vet, the full Go suite, tsc, 195 frontend tests,
npm audit clean. The startup guard and both CSPs checked against a
running server rather than only asserted.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
970 lines
28 KiB
Go
970 lines
28 KiB
Go
package docs
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/auth"
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/httputil"
|
|
)
|
|
|
|
// exportRoutes registers the download endpoints: one document
|
|
// (GET /api/docs/{id}/export?format=…) and a whole-corpus backup zip
|
|
// (GET /api/docs/export-all?format=…). The static "export-all" segment takes
|
|
// priority over the {id} param in chi's router, so the two don't collide.
|
|
func (h *Handler) exportRoutes(r chi.Router) {
|
|
r.Get("/{id}/export", h.export)
|
|
r.Get("/export-all", h.exportAll)
|
|
}
|
|
|
|
// exportAll streams every document the user owns, each rendered in the requested
|
|
// format, bundled into a single zip — a one-click "download all my writing"
|
|
// backup. Per-doc version history guards against bad edits; this guards against
|
|
// a lost disk. Reuses the same renderers as the single-document export.
|
|
func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
|
|
formatKey := r.URL.Query().Get("format")
|
|
if formatKey == "" {
|
|
formatKey = "md"
|
|
}
|
|
format, ok := exportFormats[formatKey]
|
|
if !ok {
|
|
badRequest(w, "unsupported export format")
|
|
return
|
|
}
|
|
|
|
rows, err := h.DB.Query(
|
|
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
|
FROM documents
|
|
WHERE user_id = ?
|
|
ORDER BY updated_at DESC`,
|
|
auth.UserID(r.Context()),
|
|
)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
seen := map[string]int{} // de-duplicate filenames from same-titled docs
|
|
for rows.Next() {
|
|
var doc db.Document
|
|
if err := rows.Scan(
|
|
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
|
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
|
); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
body, err := format.render(doc)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
base := sanitizeFilename(doc.Title)
|
|
if base == "" {
|
|
base = "untitled"
|
|
}
|
|
key := base + "." + format.ext
|
|
name := key
|
|
if c := seen[key]; c > 0 {
|
|
name = fmt.Sprintf("%s (%d).%s", base, c, format.ext)
|
|
}
|
|
seen[key]++
|
|
f, err := zw.Create(name)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if _, err := f.Write(body); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
filename := "petal-backup-" + time.Now().Format("2006-01-02") + ".zip"
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", buf.Len()))
|
|
_, _ = w.Write(buf.Bytes())
|
|
}
|
|
|
|
// exportFormat describes one downloadable format: how to render it and how to
|
|
// label the resulting file.
|
|
type exportFormat struct {
|
|
ext string
|
|
contentType string
|
|
render func(doc db.Document) ([]byte, error)
|
|
}
|
|
|
|
// exportFormats is the supported set. PDF is intentionally absent: a faithful,
|
|
// CJK-safe PDF needs an embedded Unicode font (~10MB into the single binary) or
|
|
// a headless browser (breaks the no-cgo, single-binary story). The frontend
|
|
// offers "Print / Save as PDF" via the browser instead, which uses the reader's
|
|
// own fonts and renders CJK correctly for free.
|
|
var exportFormats = map[string]exportFormat{
|
|
"md": {ext: "md", contentType: "text/markdown; charset=utf-8", render: renderMarkdown},
|
|
"html": {ext: "html", contentType: "text/html; charset=utf-8", render: renderHTMLFile},
|
|
"txt": {ext: "txt", contentType: "text/plain; charset=utf-8", render: renderPlainText},
|
|
"docx": {
|
|
ext: "docx",
|
|
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
render: renderDocx,
|
|
},
|
|
}
|
|
|
|
// export streams the document in the requested format as a download.
|
|
func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
|
|
formatKey := r.URL.Query().Get("format")
|
|
if formatKey == "" {
|
|
formatKey = "md"
|
|
}
|
|
format, ok := exportFormats[formatKey]
|
|
if !ok {
|
|
badRequest(w, "unsupported export format")
|
|
return
|
|
}
|
|
|
|
doc, err := h.fetch(auth.UserID(r.Context()), chi.URLParam(r, "id"))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
body, err := format.render(doc)
|
|
if err != nil {
|
|
httputil.ServerError(w, err)
|
|
return
|
|
}
|
|
|
|
filename := sanitizeFilename(doc.Title) + "." + format.ext
|
|
w.Header().Set("Content-Type", format.contentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// --- Tiptap document model --------------------------------------------------
|
|
|
|
// pmNode is one ProseMirror/Tiptap node. The tree is what the editor stores in
|
|
// Document.Content; we walk it to render every export format from one source.
|
|
type pmNode struct {
|
|
Type string `json:"type"`
|
|
Attrs map[string]any `json:"attrs"`
|
|
Marks []pmMark `json:"marks"`
|
|
Text string `json:"text"`
|
|
Content []pmNode `json:"content"`
|
|
}
|
|
|
|
type pmMark struct {
|
|
Type string `json:"type"`
|
|
Attrs map[string]any `json:"attrs"`
|
|
}
|
|
|
|
// parseDoc decodes Document.Content into a node tree. On empty or malformed JSON
|
|
// it falls back to wrapping the plain-text mirror in paragraphs, so export never
|
|
// fails just because the editor state is unusual.
|
|
func parseDoc(doc db.Document) pmNode {
|
|
var root pmNode
|
|
if err := json.Unmarshal([]byte(doc.Content), &root); err != nil || root.Type == "" {
|
|
return fallbackDoc(doc.ContentText)
|
|
}
|
|
if len(root.Content) == 0 && strings.TrimSpace(doc.ContentText) != "" {
|
|
return fallbackDoc(doc.ContentText)
|
|
}
|
|
return root
|
|
}
|
|
|
|
// fallbackDoc builds a minimal doc node from plain text, one paragraph per line.
|
|
func fallbackDoc(text string) pmNode {
|
|
root := pmNode{Type: "doc"}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
p := pmNode{Type: "paragraph"}
|
|
if line != "" {
|
|
p.Content = []pmNode{{Type: "text", Text: line}}
|
|
}
|
|
root.Content = append(root.Content, p)
|
|
}
|
|
return root
|
|
}
|
|
|
|
func (n pmNode) hasMark(t string) bool {
|
|
for _, m := range n.Marks {
|
|
if m.Type == t {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// attrStr returns a string-valued attribute (e.g. an image src), or "".
|
|
func (n pmNode) attrStr(key string) string {
|
|
if n.Attrs == nil {
|
|
return ""
|
|
}
|
|
if s, ok := n.Attrs[key].(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// markAttr returns a string attribute from the named mark (e.g. a link href).
|
|
func (n pmNode) markAttr(markType, key string) string {
|
|
for _, m := range n.Marks {
|
|
if m.Type == markType && m.Attrs != nil {
|
|
if s, ok := m.Attrs[key].(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (n pmNode) level() int {
|
|
if n.Attrs == nil {
|
|
return 1
|
|
}
|
|
if l, ok := n.Attrs["level"].(float64); ok && l >= 1 {
|
|
return int(l)
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// --- Markdown ---------------------------------------------------------------
|
|
|
|
func renderMarkdown(doc db.Document) ([]byte, error) {
|
|
root := parseDoc(doc)
|
|
var blocks []string
|
|
for _, child := range root.Content {
|
|
if s := mdBlock(child, 0); s != "" {
|
|
blocks = append(blocks, s)
|
|
}
|
|
}
|
|
out := "# " + doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
|
return []byte(out), nil
|
|
}
|
|
|
|
func mdBlock(n pmNode, depth int) string {
|
|
switch n.Type {
|
|
case "heading":
|
|
return strings.Repeat("#", n.level()) + " " + mdInline(n.Content)
|
|
case "paragraph":
|
|
return mdInline(n.Content)
|
|
case "blockquote":
|
|
var lines []string
|
|
for _, c := range n.Content {
|
|
for _, l := range strings.Split(mdBlock(c, depth), "\n") {
|
|
lines = append(lines, "> "+l)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
case "codeBlock":
|
|
return "```\n" + textContent(n) + "\n```"
|
|
case "horizontalRule":
|
|
return "---"
|
|
case "image":
|
|
alt := n.attrStr("alt")
|
|
src := safeURL(n.attrStr("src"))
|
|
if src == "" {
|
|
// Nowhere safe to point. Keep the alt text as plain prose — it is
|
|
// the part that carries meaning — rather than emitting an image
|
|
// whose destination was rejected. See safeURL.
|
|
return alt
|
|
}
|
|
return fmt.Sprintf("", alt, src)
|
|
case "table":
|
|
return mdTable(n)
|
|
case "bulletList", "orderedList":
|
|
var items []string
|
|
for i, item := range n.Content {
|
|
marker := "- "
|
|
if n.Type == "orderedList" {
|
|
marker = fmt.Sprintf("%d. ", i+1)
|
|
}
|
|
indent := strings.Repeat(" ", depth)
|
|
// A listItem holds block children (usually one paragraph).
|
|
var parts []string
|
|
for _, c := range item.Content {
|
|
parts = append(parts, mdBlock(c, depth+1))
|
|
}
|
|
items = append(items, indent+marker+strings.TrimSpace(strings.Join(parts, "\n")))
|
|
}
|
|
return strings.Join(items, "\n")
|
|
default:
|
|
// Unknown block: render any inline text it carries.
|
|
if len(n.Content) > 0 {
|
|
return mdInline(n.Content)
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func mdInline(nodes []pmNode) string {
|
|
var b strings.Builder
|
|
for _, n := range nodes {
|
|
switch n.Type {
|
|
case "text":
|
|
b.WriteString(applyMdMarks(n))
|
|
case "hardBreak":
|
|
b.WriteString(" \n")
|
|
default:
|
|
b.WriteString(mdInline(n.Content))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// mdTable renders a table node as a GitHub-flavored Markdown table. The first
|
|
// row is used as the header (Markdown tables require one); a separator row is
|
|
// inserted after it. Cell text is flattened to inline Markdown.
|
|
func mdTable(n pmNode) string {
|
|
var rows [][]string
|
|
for _, row := range n.Content {
|
|
if row.Type != "tableRow" {
|
|
continue
|
|
}
|
|
var cells []string
|
|
for _, cell := range row.Content {
|
|
// A cell holds block children (usually one paragraph); flatten them.
|
|
var parts []string
|
|
for _, c := range cell.Content {
|
|
parts = append(parts, mdInline(c.Content))
|
|
}
|
|
// Escape pipes so cell content doesn't break the column layout.
|
|
cells = append(cells, strings.ReplaceAll(strings.TrimSpace(strings.Join(parts, " ")), "|", "\\|"))
|
|
}
|
|
rows = append(rows, cells)
|
|
}
|
|
if len(rows) == 0 {
|
|
return ""
|
|
}
|
|
cols := 0
|
|
for _, r := range rows {
|
|
if len(r) > cols {
|
|
cols = len(r)
|
|
}
|
|
}
|
|
pad := func(r []string) string {
|
|
for len(r) < cols {
|
|
r = append(r, "")
|
|
}
|
|
return "| " + strings.Join(r, " | ") + " |"
|
|
}
|
|
var lines []string
|
|
lines = append(lines, pad(rows[0]))
|
|
sep := make([]string, cols)
|
|
for i := range sep {
|
|
sep[i] = "---"
|
|
}
|
|
lines = append(lines, "| "+strings.Join(sep, " | ")+" |")
|
|
for _, r := range rows[1:] {
|
|
lines = append(lines, pad(r))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func applyMdMarks(n pmNode) string {
|
|
t := n.Text
|
|
if n.hasMark("code") {
|
|
return "`" + t + "`" // code spans don't combine with other emphasis
|
|
}
|
|
if n.hasMark("bold") {
|
|
t = "**" + t + "**"
|
|
}
|
|
if n.hasMark("italic") {
|
|
t = "*" + t + "*"
|
|
}
|
|
if n.hasMark("strike") {
|
|
t = "~~" + t + "~~"
|
|
}
|
|
if n.hasMark("underline") {
|
|
t = "<u>" + t + "</u>"
|
|
}
|
|
// Same rule as the HTML export: plenty of Markdown renderers pass a
|
|
// `javascript:` destination straight through into an <a href>. See safeURL.
|
|
if href := safeURL(n.markAttr("link", "href")); href != "" {
|
|
t = "[" + t + "](" + href + ")"
|
|
}
|
|
return t
|
|
}
|
|
|
|
// --- Plain text -------------------------------------------------------------
|
|
|
|
func renderPlainText(doc db.Document) ([]byte, error) {
|
|
root := parseDoc(doc)
|
|
var blocks []string
|
|
for _, child := range root.Content {
|
|
if s := txtBlock(child); s != "" {
|
|
blocks = append(blocks, s)
|
|
}
|
|
}
|
|
out := doc.Title + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
|
return []byte(out), nil
|
|
}
|
|
|
|
func txtBlock(n pmNode) string {
|
|
switch n.Type {
|
|
case "bulletList", "orderedList":
|
|
var items []string
|
|
for i, item := range n.Content {
|
|
marker := "• "
|
|
if n.Type == "orderedList" {
|
|
marker = fmt.Sprintf("%d. ", i+1)
|
|
}
|
|
items = append(items, marker+strings.TrimSpace(textContent(item)))
|
|
}
|
|
return strings.Join(items, "\n")
|
|
case "horizontalRule":
|
|
return "----------"
|
|
default:
|
|
return textContent(n)
|
|
}
|
|
}
|
|
|
|
// textContent flattens all descendant text, joining hardBreaks as newlines.
|
|
func textContent(n pmNode) string {
|
|
if n.Type == "text" {
|
|
return n.Text
|
|
}
|
|
if n.Type == "hardBreak" {
|
|
return "\n"
|
|
}
|
|
var b strings.Builder
|
|
for _, c := range n.Content {
|
|
b.WriteString(textContent(c))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// --- HTML -------------------------------------------------------------------
|
|
|
|
func renderHTMLFile(doc db.Document) ([]byte, error) {
|
|
root := parseDoc(doc)
|
|
var body strings.Builder
|
|
for _, child := range root.Content {
|
|
body.WriteString(htmlBlock(child))
|
|
}
|
|
page := fmt.Sprintf(htmlTemplate, htmlEscape(doc.Title), htmlEscape(doc.Title), body.String())
|
|
return []byte(page), nil
|
|
}
|
|
|
|
// htmlTemplate is a standalone, self-contained page with a warm, readable
|
|
// stylesheet and a CJK-first font stack so exported writing looks like Petal,
|
|
// not a raw dump. No external assets — opens offline anywhere.
|
|
const htmlTemplate = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>%s</title>
|
|
<style>
|
|
:root { color-scheme: light; }
|
|
body {
|
|
font-family: "Georgia", "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
|
|
line-height: 1.75; color: #463a3f; background: #fffafb;
|
|
max-width: 42rem; margin: 3rem auto; padding: 0 1.5rem;
|
|
}
|
|
h1, h2, h3 { font-family: "Georgia", "Songti SC", serif; color: #b04a6a; line-height: 1.3; }
|
|
h1 { font-size: 1.9rem; border-bottom: 2px solid #f6d6e0; padding-bottom: .4rem; }
|
|
blockquote { border-left: 3px solid #f3b6c8; margin: 1rem 0; padding: .2rem 1rem; color: #6b5860; background: #fff2f6; }
|
|
code { background: #fdeef3; padding: .1rem .35rem; border-radius: .3rem; font-size: .9em; }
|
|
pre { background: #fdeef3; padding: 1rem; border-radius: .6rem; overflow-x: auto; }
|
|
pre code { background: none; padding: 0; }
|
|
hr { border: none; border-top: 1px solid #f3cdd9; margin: 2rem 0; }
|
|
a { color: #b04a6a; }
|
|
ul, ol { padding-left: 1.4rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>%s</h1>
|
|
%s</body>
|
|
</html>
|
|
`
|
|
|
|
func htmlBlock(n pmNode) string {
|
|
switch n.Type {
|
|
case "heading":
|
|
tag := fmt.Sprintf("h%d", clampHeading(n.level()))
|
|
return fmt.Sprintf("<%s>%s</%s>\n", tag, htmlInline(n.Content), tag)
|
|
case "paragraph":
|
|
inner := htmlInline(n.Content)
|
|
if inner == "" {
|
|
return "<p><br></p>\n"
|
|
}
|
|
return "<p>" + inner + "</p>\n"
|
|
case "blockquote":
|
|
var b strings.Builder
|
|
for _, c := range n.Content {
|
|
b.WriteString(htmlBlock(c))
|
|
}
|
|
return "<blockquote>" + b.String() + "</blockquote>\n"
|
|
case "codeBlock":
|
|
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
|
|
case "horizontalRule":
|
|
return "<hr>\n"
|
|
case "image":
|
|
alt := htmlEscape(n.attrStr("alt"))
|
|
src := safeURL(n.attrStr("src"))
|
|
if src == "" {
|
|
// Nowhere safe to point: keep the alt text, which is the part that
|
|
// carries meaning, rather than emitting a broken image.
|
|
if alt == "" {
|
|
return ""
|
|
}
|
|
return "<p>" + alt + "</p>\n"
|
|
}
|
|
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\n", htmlEscape(src), alt)
|
|
case "table":
|
|
return htmlTable(n)
|
|
case "bulletList", "orderedList":
|
|
tag := "ul"
|
|
if n.Type == "orderedList" {
|
|
tag = "ol"
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("<" + tag + ">\n")
|
|
for _, item := range n.Content {
|
|
var inner strings.Builder
|
|
for _, c := range item.Content {
|
|
// Unwrap a lone paragraph so list items aren't double-spaced.
|
|
if c.Type == "paragraph" {
|
|
inner.WriteString(htmlInline(c.Content))
|
|
} else {
|
|
inner.WriteString(htmlBlock(c))
|
|
}
|
|
}
|
|
b.WriteString("<li>" + inner.String() + "</li>\n")
|
|
}
|
|
b.WriteString("</" + tag + ">\n")
|
|
return b.String()
|
|
default:
|
|
if len(n.Content) > 0 {
|
|
return "<p>" + htmlInline(n.Content) + "</p>\n"
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// htmlTable renders a table node as an HTML <table>. tableHeader cells become
|
|
// <th>, tableCell cells become <td>; each cell's block children are flattened
|
|
// to inline HTML.
|
|
func htmlTable(n pmNode) string {
|
|
var b strings.Builder
|
|
b.WriteString("<table>\n")
|
|
for _, row := range n.Content {
|
|
if row.Type != "tableRow" {
|
|
continue
|
|
}
|
|
b.WriteString("<tr>")
|
|
for _, cell := range row.Content {
|
|
tag := "td"
|
|
if cell.Type == "tableHeader" {
|
|
tag = "th"
|
|
}
|
|
var inner strings.Builder
|
|
for _, c := range cell.Content {
|
|
if c.Type == "paragraph" {
|
|
inner.WriteString(htmlInline(c.Content))
|
|
} else {
|
|
inner.WriteString(htmlBlock(c))
|
|
}
|
|
}
|
|
b.WriteString("<" + tag + ">" + inner.String() + "</" + tag + ">")
|
|
}
|
|
b.WriteString("</tr>\n")
|
|
}
|
|
b.WriteString("</table>\n")
|
|
return b.String()
|
|
}
|
|
|
|
func htmlInline(nodes []pmNode) string {
|
|
var b strings.Builder
|
|
for _, n := range nodes {
|
|
switch n.Type {
|
|
case "text":
|
|
b.WriteString(applyHTMLMarks(n))
|
|
case "hardBreak":
|
|
b.WriteString("<br>")
|
|
default:
|
|
b.WriteString(htmlInline(n.Content))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func applyHTMLMarks(n pmNode) string {
|
|
t := htmlEscape(n.Text)
|
|
if n.hasMark("code") {
|
|
return "<code>" + t + "</code>"
|
|
}
|
|
if n.hasMark("bold") {
|
|
t = "<strong>" + t + "</strong>"
|
|
}
|
|
if n.hasMark("italic") {
|
|
t = "<em>" + t + "</em>"
|
|
}
|
|
if n.hasMark("underline") {
|
|
t = "<u>" + t + "</u>"
|
|
}
|
|
if n.hasMark("strike") {
|
|
t = "<s>" + t + "</s>"
|
|
}
|
|
if n.hasMark("highlight") {
|
|
t = "<mark>" + t + "</mark>"
|
|
}
|
|
// An unsafe href is dropped, not the link: the words stay, they just stop
|
|
// being clickable. See safeURL.
|
|
if href := safeURL(n.markAttr("link", "href")); href != "" {
|
|
t = fmt.Sprintf("<a href=\"%s\">%s</a>", htmlEscape(href), t)
|
|
}
|
|
return t
|
|
}
|
|
|
|
func clampHeading(level int) int {
|
|
if level < 1 {
|
|
return 1
|
|
}
|
|
if level > 6 {
|
|
return 6
|
|
}
|
|
return level
|
|
}
|
|
|
|
// --- DOCX -------------------------------------------------------------------
|
|
|
|
// renderDocx builds a minimal but valid .docx (Office Open XML) in pure Go: a
|
|
// zip of the few XML parts Word needs. Bold/italic/underline/strike map to run
|
|
// properties; headings use Word's built-in styles; lists are rendered with a
|
|
// bullet/number prefix (no numbering.xml dependency). CJK renders with the
|
|
// reader's own fonts, so no font embedding is required.
|
|
func renderDocx(doc db.Document) ([]byte, error) {
|
|
root := parseDoc(doc)
|
|
|
|
var body strings.Builder
|
|
body.WriteString(docxHeading(doc.Title, 1))
|
|
for _, child := range root.Content {
|
|
body.WriteString(docxBlock(child))
|
|
}
|
|
|
|
document := xmlHeader + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
|
`<w:body>` + body.String() +
|
|
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:bottom="1440" w:left="1440" w:right="1440"/></w:sectPr>` +
|
|
`</w:body></w:document>`
|
|
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
parts := []struct{ name, content string }{
|
|
{"[Content_Types].xml", docxContentTypes},
|
|
{"_rels/.rels", docxRootRels},
|
|
{"word/_rels/document.xml.rels", docxDocRels},
|
|
{"word/document.xml", document},
|
|
}
|
|
for _, p := range parts {
|
|
fw, err := zw.Create(p.name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := fw.Write([]byte(p.content)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
const (
|
|
xmlHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n"
|
|
docxContentTypes = xmlHeader + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
|
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
|
|
`<Default Extension="xml" ContentType="application/xml"/>` +
|
|
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
|
|
`</Types>`
|
|
docxRootRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
|
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
|
|
`</Relationships>`
|
|
docxDocRels = xmlHeader + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
|
)
|
|
|
|
func docxBlock(n pmNode) string {
|
|
switch n.Type {
|
|
case "heading":
|
|
return docxHeading(textContent(n), n.level())
|
|
case "paragraph":
|
|
return docxPara(n, "")
|
|
case "blockquote":
|
|
var b strings.Builder
|
|
for _, c := range n.Content {
|
|
b.WriteString(docxPara(c, "Quote"))
|
|
}
|
|
return b.String()
|
|
case "codeBlock":
|
|
// One paragraph per line preserves layout without a code style.
|
|
var b strings.Builder
|
|
for _, line := range strings.Split(textContent(n), "\n") {
|
|
b.WriteString(`<w:p><w:r><w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/></w:rPr>` +
|
|
`<w:t xml:space="preserve">` + xmlEscape(line) + `</w:t></w:r></w:p>`)
|
|
}
|
|
return b.String()
|
|
case "horizontalRule":
|
|
return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
|
|
case "image":
|
|
// Image bytes aren't embedded (that needs media parts); leave a labeled
|
|
// placeholder so the reader knows an image belonged here.
|
|
label := n.attrStr("alt")
|
|
if label == "" {
|
|
label = "image"
|
|
}
|
|
return docxPara(pmNode{Content: []pmNode{{Type: "text", Text: "[" + label + "]", Marks: []pmMark{{Type: "italic"}}}}}, "")
|
|
case "table":
|
|
return docxTable(n)
|
|
case "bulletList", "orderedList":
|
|
var b strings.Builder
|
|
for i, item := range n.Content {
|
|
prefix := "• "
|
|
if n.Type == "orderedList" {
|
|
prefix = fmt.Sprintf("%d. ", i+1)
|
|
}
|
|
for _, c := range item.Content {
|
|
b.WriteString(docxPara(c, "", prefix))
|
|
}
|
|
}
|
|
return b.String()
|
|
default:
|
|
if len(n.Content) > 0 {
|
|
return docxPara(n, "")
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// docxTable renders a table node as a Word table (w:tbl) with single-line
|
|
// borders. Header cells get a shaded background; every cell's block children are
|
|
// rendered as paragraphs inside the cell.
|
|
func docxTable(n pmNode) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/>` +
|
|
`<w:tblBorders>` +
|
|
`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
|
|
`</w:tblBorders></w:tblPr>`)
|
|
for _, row := range n.Content {
|
|
if row.Type != "tableRow" {
|
|
continue
|
|
}
|
|
b.WriteString("<w:tr>")
|
|
for _, cell := range row.Content {
|
|
b.WriteString("<w:tc><w:tcPr>")
|
|
if cell.Type == "tableHeader" {
|
|
b.WriteString(`<w:shd w:val="clear" w:color="auto" w:fill="FFF0F5"/>`)
|
|
}
|
|
b.WriteString("</w:tcPr>")
|
|
// A cell must contain at least one paragraph to be valid.
|
|
if len(cell.Content) == 0 {
|
|
b.WriteString("<w:p/>")
|
|
}
|
|
for _, c := range cell.Content {
|
|
b.WriteString(docxBlock(c))
|
|
}
|
|
b.WriteString("</w:tc>")
|
|
}
|
|
b.WriteString("</w:tr>")
|
|
}
|
|
b.WriteString("</w:tbl>")
|
|
// Word needs a paragraph after a table; otherwise consecutive tables merge.
|
|
b.WriteString("<w:p/>")
|
|
return b.String()
|
|
}
|
|
|
|
// docxPara renders a block's inline children as a Word paragraph. An optional
|
|
// style name (e.g. "Quote") and an optional literal text prefix (for list
|
|
// markers) may be supplied.
|
|
func docxPara(n pmNode, style string, prefix ...string) string {
|
|
var b strings.Builder
|
|
b.WriteString("<w:p>")
|
|
if style != "" {
|
|
b.WriteString(`<w:pPr><w:pStyle w:val="` + style + `"/></w:pPr>`)
|
|
}
|
|
if len(prefix) > 0 && prefix[0] != "" {
|
|
b.WriteString(docxRun(pmNode{Type: "text", Text: prefix[0]}))
|
|
}
|
|
b.WriteString(docxInline(n.Content))
|
|
b.WriteString("</w:p>")
|
|
return b.String()
|
|
}
|
|
|
|
func docxHeading(text string, level int) string {
|
|
return `<w:p><w:pPr><w:pStyle w:val="Heading` + fmt.Sprintf("%d", clampHeading(level)) + `"/></w:pPr>` +
|
|
docxRun(pmNode{Type: "text", Text: text}) + `</w:p>`
|
|
}
|
|
|
|
func docxInline(nodes []pmNode) string {
|
|
var b strings.Builder
|
|
for _, n := range nodes {
|
|
switch n.Type {
|
|
case "text":
|
|
b.WriteString(docxRun(n))
|
|
case "hardBreak":
|
|
b.WriteString(`<w:r><w:br/></w:r>`)
|
|
default:
|
|
b.WriteString(docxInline(n.Content))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func docxRun(n pmNode) string {
|
|
var props strings.Builder
|
|
if n.hasMark("bold") {
|
|
props.WriteString("<w:b/>")
|
|
}
|
|
if n.hasMark("italic") {
|
|
props.WriteString("<w:i/>")
|
|
}
|
|
if n.hasMark("underline") {
|
|
props.WriteString(`<w:u w:val="single"/>`)
|
|
}
|
|
if n.hasMark("strike") {
|
|
props.WriteString("<w:strike/>")
|
|
}
|
|
if n.hasMark("highlight") {
|
|
// Word's text highlight only supports a fixed palette of named colors.
|
|
props.WriteString(`<w:highlight w:val="yellow"/>`)
|
|
}
|
|
if n.markAttr("link", "href") != "" {
|
|
// Without hyperlink relationships, style links as blue underlined text so
|
|
// they at least read as links (the URL itself is preserved in md/html).
|
|
props.WriteString(`<w:color w:val="2563EB"/><w:u w:val="single"/>`)
|
|
}
|
|
rpr := ""
|
|
if props.Len() > 0 {
|
|
rpr = "<w:rPr>" + props.String() + "</w:rPr>"
|
|
}
|
|
return `<w:r>` + rpr + `<w:t xml:space="preserve">` + xmlEscape(n.Text) + `</w:t></w:r>`
|
|
}
|
|
|
|
// --- small helpers ----------------------------------------------------------
|
|
|
|
func htmlEscape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
|
return r.Replace(s)
|
|
}
|
|
|
|
// safeURLSchemes are the schemes an exported document may point at. Escaping
|
|
// makes a URL safe to sit inside an attribute; it says nothing about what
|
|
// happens when the attribute is followed, and `javascript:` survives it
|
|
// untouched.
|
|
//
|
|
// The toolbar can't produce one — it prefixes anything it doesn't recognise
|
|
// with https:// — but the toolbar is not the only way in: PUT /api/docs/{id}
|
|
// stores whatever Tiptap JSON it is given. And an export is the one artifact
|
|
// here that is *meant* to leave: the passport and the .html backup are files a
|
|
// writer hands to a teacher or an editor, opened on a machine that has no
|
|
// reason to trust them. A link that runs code when clicked is not something to
|
|
// ship inside one.
|
|
//
|
|
// Relative and fragment links pass through: they're how a document refers to
|
|
// its own headings, and they can't reach anything.
|
|
var safeURLSchemes = map[string]bool{
|
|
"http": true, "https": true, "mailto": true, "tel": true, "ftp": true,
|
|
}
|
|
|
|
// safeURL returns u if it is safe to follow from an exported file, and "" if it
|
|
// isn't. A dropped href leaves the link text in place — the reader loses a
|
|
// destination, never the writing.
|
|
func safeURL(u string) string {
|
|
trimmed := strings.TrimSpace(u)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
// A scheme is everything before the first ':', but only when no '/', '?' or
|
|
// '#' comes first — otherwise "notes/a:b" would read as the "notes/a" scheme.
|
|
// Nothing before a colon means a relative or fragment link, which is fine.
|
|
if i := strings.IndexAny(trimmed, ":/?#"); i >= 0 && trimmed[i] == ':' {
|
|
// Control characters and whitespace are stripped by browsers *before*
|
|
// the scheme is read, so "java\nscript:" is javascript:. Fold them out
|
|
// before deciding rather than after.
|
|
scheme := strings.Map(func(r rune) rune {
|
|
if r <= ' ' || r == 0x7f {
|
|
return -1
|
|
}
|
|
return r
|
|
}, trimmed[:i])
|
|
if !safeURLSchemes[strings.ToLower(scheme)] {
|
|
return ""
|
|
}
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func xmlEscape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
// sanitizeFilename makes a title safe as a download filename, preserving CJK and
|
|
// most letters while dropping path separators and control characters.
|
|
func sanitizeFilename(title string) string {
|
|
title = strings.TrimSpace(title)
|
|
if title == "" {
|
|
return "untitled"
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range title {
|
|
switch {
|
|
case r < 0x20, r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
|
|
b.WriteRune('-')
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
out := strings.TrimSpace(b.String())
|
|
if out == "" {
|
|
return "untitled"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// urlEscapeFilename percent-encodes a filename for the RFC 5987 filename*=
|
|
// Content-Disposition form, which carries UTF-8 (CJK titles) safely.
|
|
func urlEscapeFilename(s string) string {
|
|
var b strings.Builder
|
|
for _, c := range []byte(s) {
|
|
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
|
c == '-' || c == '_' || c == '.' || c == '~' {
|
|
b.WriteByte(c)
|
|
} else {
|
|
fmt.Fprintf(&b, "%%%02X", c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|