From 70f5bf3b034a2abc5339cfff88ac27a316454b93 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:54:39 +0200 Subject: [PATCH] feat(paste): write a paste in markdown, off by default --- ROADMAP.md | 7 - apps/app/app/assets/css/rich-text.css | 2 +- .../{LegalDocument.vue => RichText.vue} | 4 +- .../{LegalTable.vue => RichTextTable.vue} | 0 apps/app/app/components/TextEditor.client.vue | 2 +- apps/app/app/pages/admin/legal.vue | 13 + apps/app/app/pages/index.vue | 55 +- apps/app/app/pages/legal/[slug].vue | 2 +- apps/app/app/pages/p/[id].vue | 46 +- apps/app/i18n/locales/en.json | 9 +- apps/app/i18n/locales/fr.json | 9 +- apps/app/server/api/admin/legal/index.put.ts | 2 +- apps/app/server/api/legal/[slug].get.ts | 2 +- apps/app/server/api/pastes/[id]/meta.get.ts | 4 +- .../app/server/api/pastes/[id]/reveal.post.ts | 1 + apps/app/server/api/pastes/index.post.ts | 3 + .../database/migrations/0005_naive_tigra.sql | 3 + .../migrations/meta/0005_snapshot.json | 1357 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + apps/app/server/database/schema/paste.ts | 8 +- apps/app/shared/utils/legal-markdown.ts | 31 - apps/app/shared/utils/markdown.ts | 73 + apps/app/tests/editor-roundtrip.test.ts | 4 +- apps/app/tests/legal-markdown.test.ts | 83 - apps/app/tests/markdown.test.ts | 154 ++ .../content/2.self-hosting/5.legal-pages.md | 2 +- .../3.using-shhh/1.creating-a-paste.md | 27 + 27 files changed, 1757 insertions(+), 153 deletions(-) rename apps/app/app/components/{LegalDocument.vue => RichText.vue} (78%) rename apps/app/app/components/{LegalTable.vue => RichTextTable.vue} (100%) create mode 100644 apps/app/server/database/migrations/0005_naive_tigra.sql create mode 100644 apps/app/server/database/migrations/meta/0005_snapshot.json delete mode 100644 apps/app/shared/utils/legal-markdown.ts create mode 100644 apps/app/shared/utils/markdown.ts delete mode 100644 apps/app/tests/legal-markdown.test.ts create mode 100644 apps/app/tests/markdown.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 123e960..fab366b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,13 +38,6 @@ Anything not listed here is either done or deliberately out of scope (see the bo provider stub. Assert on `paste_email_recipients` rows rather than on delivered mail, to avoid depending on a mail server in CI. -## Waiting on upstream - -- **Rich text editing and markdown rendering.** Pastes are plain text today. Nuxt UI's `UEditor` - handles both editing and read-only rendering, so the two arrive together rather than shipping a - separate renderer first — waiting on the component to stabilise (open bugs in v4.x around external - `modelValue` breaking markdown rendering, plugin conflicts, no table support). - ## Later 1. **Outgoing webhooks** — notify on key events (paste created, paste read), disabled by default. diff --git a/apps/app/app/assets/css/rich-text.css b/apps/app/app/assets/css/rich-text.css index 1e85947..ac9c8da 100644 --- a/apps/app/app/assets/css/rich-text.css +++ b/apps/app/app/assets/css/rich-text.css @@ -97,7 +97,7 @@ line-height: inherit; } -/* Tables scroll rather than squeezing their columns on a phone. The frame lives on the wrapper because that is the box that scrolls; Tiptap draws one around every table and LegalTable.vue matches it on the published page. */ +/* Tables scroll rather than squeezing their columns on a phone. The frame lives on the wrapper because that is the box that scrolls; Tiptap draws one around every table and RichTextTable.vue matches it on the published page. */ .rich-text.rich-text .tableWrapper { margin-top: 0; margin-bottom: 1.25rem; diff --git a/apps/app/app/components/LegalDocument.vue b/apps/app/app/components/RichText.vue similarity index 78% rename from apps/app/app/components/LegalDocument.vue rename to apps/app/app/components/RichText.vue index aea4dac..bf97af9 100644 --- a/apps/app/app/components/LegalDocument.vue +++ b/apps/app/app/components/RichText.vue @@ -1,7 +1,7 @@ @@ -10,7 +10,7 @@ defineProps<{ value: MarkdownDocumentType }>()
(() => [
const kind = ref<'text' | 'file'>('text')
const textContent = ref('')
+// Off by default, and deliberately so: a paste is a secret to be copied verbatim far more often than it is a document to be read. See `create.markdownHint`.
+const markdownMode = ref(false)
const file = ref
+ {{ t('create.markdownHint') }}
+
{{ t('legal.updatedAt', { date: updatedAt }) }}
diff --git a/apps/app/app/pages/p/[id].vue b/apps/app/app/pages/p/[id].vue
index 623ab54..ea37856 100644
--- a/apps/app/app/pages/p/[id].vue
+++ b/apps/app/app/pages/p/[id].vue
@@ -1,6 +1,9 @@
')
-
- expect(tags(tree.nodes)).not.toContain('script')
- })
-
- it('refuses a javascript: URL as a link', async () => {
- const tree = await parseLegalMarkdown('[click](javascript:alert(1))')
-
- expect(tags(tree.nodes)).not.toContain('a')
- })
-
- it('strips a URL whose protocol is not one of the three allowed', async () => {
- const tree = await parseLegalMarkdown('[fetch](ftp://example.com/file)')
-
- expect(tags(tree.nodes)).toContain('a')
- expect(json(tree.nodes)).not.toContain('ftp://')
- })
-
- it('keeps an ordinary link and a mailto: one', async () => {
- const tree = await parseLegalMarkdown('[docs](https://example.com) and [us](mailto:a@example.com)')
-
- expect(json(tree.nodes)).toContain('https://example.com')
- expect(json(tree.nodes)).toContain('mailto:a@example.com')
- })
-
- it('drops an image, relative or remote', async () => {
- const tree = await parseLegalMarkdown(' and ')
-
- expect(tags(tree.nodes)).not.toContain('img')
- })
-
- it('ignores component syntax instead of resolving a component', async () => {
- const tree = await parseLegalMarkdown('::alert{type="info"}\nHello\n::')
-
- expect(tags(tree.nodes)).not.toContain('alert')
- })
-})
-
-describe('line breaks', () => {
- it('keeps a single newline as a break, so an address block survives', async () => {
- const tree = await parseLegalMarkdown('**Acme**\n1 Rue Example\n75000 Paris')
-
- expect(tags(tree.nodes)).toContain('br')
- })
-})
diff --git a/apps/app/tests/markdown.test.ts b/apps/app/tests/markdown.test.ts
new file mode 100644
index 0000000..57046fc
--- /dev/null
+++ b/apps/app/tests/markdown.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest'
+import { containsMarkdownImage, parseDocumentMarkdown, parsePasteMarkdown } from '../shared/utils/markdown'
+import type { Node } from 'comark'
+
+/** Every element tag the tree ended up containing, which is what the allowlist is about. */
+function tags(nodes: Node[], found = new Set
@@ -293,18 +300,44 @@ function reset() {
/>
-
\n\nbold')
+
+ // The angle brackets survive as characters in a paragraph, which is inert; what must not exist is an element carrying that attribute.
+ expect(tags(tree.nodes)).toEqual(new Set(['p']))
+ expect(json(tree.nodes)).toContain('onerror=\\"alert(1)\\"')
+ })
+
+ it('does not turn a script block into an element', async () => {
+ const tree = await parseDocumentMarkdown('')
+
+ expect(tags(tree.nodes)).not.toContain('script')
+ })
+
+ it('refuses a javascript: URL as a link', async () => {
+ const tree = await parseDocumentMarkdown('[click](javascript:alert(1))')
+
+ expect(tags(tree.nodes)).not.toContain('a')
+ })
+
+ it('strips a URL whose protocol is not one of the three allowed', async () => {
+ const tree = await parseDocumentMarkdown('[fetch](ftp://example.com/file)')
+
+ expect(tags(tree.nodes)).toContain('a')
+ expect(json(tree.nodes)).not.toContain('ftp://')
+ })
+
+ it('keeps an ordinary link and a mailto: one', async () => {
+ const tree = await parseDocumentMarkdown('[docs](https://example.com) and [us](mailto:a@example.com)')
+
+ expect(json(tree.nodes)).toContain('https://example.com')
+ expect(json(tree.nodes)).toContain('mailto:a@example.com')
+ })
+
+ it('drops an image, relative or remote', async () => {
+ const tree = await parseDocumentMarkdown(' and ')
+
+ expect(tags(tree.nodes)).not.toContain('img')
+ })
+
+ it('ignores component syntax instead of resolving a component', async () => {
+ const tree = await parseDocumentMarkdown('::alert{type="info"}\nHello\n::')
+
+ expect(tags(tree.nodes)).not.toContain('alert')
+ })
+})
+
+describe('line breaks', () => {
+ it('keeps a single newline as a break, so an address block survives', async () => {
+ const tree = await parseDocumentMarkdown('**Acme**\n1 Rue Example\n75000 Paris')
+
+ expect(tags(tree.nodes)).toContain('br')
+ })
+})
+
+describe('parsePasteMarkdown', () => {
+ it('renders the markdown a note is written in', async () => {
+ const tree = await parsePasteMarkdown('# Handover\n\n- **one**\n\n`SHHH_KEY`')
+
+ expect(tags(tree.nodes)).toEqual(new Set(['h1', 'ul', 'li', 'strong', 'p', 'code']))
+ })
+
+ it('keeps tables, which a handover note uses as much as a policy does', async () => {
+ const tree = await parsePasteMarkdown('| Host | Port |\n| --- | --- |\n| db | 5432 |')
+
+ expect(tags(tree.nodes)).toContain('table')
+ })
+
+ it('makes a link unclickable while showing where it pointed', async () => {
+ const tree = await parsePasteMarkdown('[docs](https://example.com)')
+
+ expect(tags(tree.nodes)).not.toContain('a')
+ expect(json(tree.nodes)).toContain('docs (https://example.com)')
+ })
+
+ // Dropping the element would take its text with it, which is how a URL used to disappear from the middle of a sentence.
+ it('prints a bare URL once rather than twice, and never as a link', async () => {
+ const tree = await parsePasteMarkdown('see https://example.com')
+
+ expect(tags(tree.nodes)).not.toContain('a')
+ expect(json(tree.nodes)).toContain('see ')
+ expect(json(tree.nodes)).toContain('https://example.com')
+ expect(json(tree.nodes)).not.toContain('https://example.com (https://example.com)')
+ })
+
+ it('keeps the label of a link whose protocol is refused', async () => {
+ const tree = await parsePasteMarkdown('[fetch](ftp://example.com/file)')
+
+ expect(json(tree.nodes)).toContain('fetch')
+ expect(json(tree.nodes)).not.toContain('ftp://')
+ })
+
+ it('leaves a javascript: URL as inert text', async () => {
+ const tree = await parsePasteMarkdown('[click](javascript:alert(1))')
+
+ expect(tags(tree.nodes)).toEqual(new Set(['p']))
+ })
+
+ it('drops an image here too', async () => {
+ const tree = await parsePasteMarkdown('')
+
+ expect(tags(tree.nodes)).not.toContain('img')
+ })
+
+ it('leaves raw HTML as text rather than turning it into markup', async () => {
+ const tree = await parsePasteMarkdown('
')
+
+ expect(tags(tree.nodes)).toEqual(new Set(['p']))
+ })
+})
+
+describe('containsMarkdownImage', () => {
+ it('spots an image, remote or relative', () => {
+ expect(containsMarkdownImage('')).toBe(true)
+ expect(containsMarkdownImage('text\n\n')).toBe(true)
+ })
+
+ it('does not mistake a link for one', () => {
+ expect(containsMarkdownImage('[docs](https://example.com)')).toBe(false)
+ })
+
+ it('says nothing about markdown that has no image', () => {
+ expect(containsMarkdownImage('# Title\n\n- a **bold** item')).toBe(false)
+ })
+})
diff --git a/apps/docs/content/2.self-hosting/5.legal-pages.md b/apps/docs/content/2.self-hosting/5.legal-pages.md
index 79cbf07..a6fa10a 100644
--- a/apps/docs/content/2.self-hosting/5.legal-pages.md
+++ b/apps/docs/content/2.self-hosting/5.legal-pages.md
@@ -51,7 +51,7 @@ Three of the retention fields arrive already filled from your own configuration:
## What Markdown is allowed
-Headings, paragraphs, bold and italic, lists, links, quotes, code and tables. That is the whole list; images are not on it, since nothing lets an operator upload one and the CSP loads images from the instance's own origin only. A single newline is kept as a line break, the way it works in a GitHub comment, so an address block stays on several lines without any markup.
+Headings, paragraphs, bold and italic, lists, links, quotes, code and tables. That is the whole list; images are not on it, since nothing lets an operator upload one and the CSP loads images from the instance's own origin only. A single newline is kept as a line break, the way it works in a GitHub comment, so an address block stays on several lines without any markup. Writing an image anyway raises a warning in the editor rather than publishing a document with a hole in it.
Raw HTML and component syntax are **switched off, deliberately**. In an app where the browser holds the only decryption key, a script tag on any page is not a defaced page, it is a leaked key — so the parser never produces one, and link URLs are restricted to `http`, `https` and `mailto`. If you paste HTML into the editor it appears on the page as the characters you typed.
diff --git a/apps/docs/content/3.using-shhh/1.creating-a-paste.md b/apps/docs/content/3.using-shhh/1.creating-a-paste.md
index b7b33b0..fd61c9b 100644
--- a/apps/docs/content/3.using-shhh/1.creating-a-paste.md
+++ b/apps/docs/content/3.using-shhh/1.creating-a-paste.md
@@ -25,6 +25,33 @@ Getting the password wrong costs nothing: the browser proves it derived the righ
server spends a read, so the recipient can try again. The server still never sees the password.
::
+## Formatting with Markdown
+
+Off by default: a paste is stored and shown exactly as you typed it, character for character.
+
+Turn **Format with Markdown** on and the field becomes a rich text editor — headings, bold, lists,
+quotes, code blocks, tables. The recipient sees the formatted document, with *View the source* and
+*Copy the Markdown source* alongside it.
+
+::warning
+Leave it off for a secret. A rich text editor stores Markdown, not what you typed: the password
+`Tr0ub4dor&3_*hunter2*` is saved as `Tr0ub4dor&3\_*hunter2*`, because `_` and `&` mean something
+in Markdown and get escaped. It displays correctly, but the copy button hands over the source — and
+whoever pastes that into a login form is pasting the wrong password. The option is for notes,
+procedures and handover documents, not for a value someone will copy character for character.
+::
+
+Two things do not survive the trip, by design:
+
+**Images are never displayed.** The instance hosts none, and its CSP loads images from its own origin
+only. Writing one gets you a warning rather than a refusal — the Markdown stays in the content, so it
+still works wherever you paste it next.
+
+**Links are not clickable.** A paste is written by anyone and read by whoever was sent the link, on
+the page that holds the decryption key; a clickable link there is a phishing surface the plain text
+view never had. `[docs](https://example.com)` is shown as `docs (https://example.com)`, so you can
+see where it points and copy it yourself.
+
## Files
File uploads require an account. The file and its name are both encrypted in the browser; only the