feat(paste): write a paste in markdown, off by default - #44
Merged
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Several new references to shared markdown utilities are missing imports in changed files, which will cause runtime/build failures.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an opt-in Markdown mode for text pastes (stored as Markdown source but rendered safely on read), while preserving the existing plain-text “copy exact bytes” behavior as the default.
Changes:
- Introduces
paste_format/formatonpastes(defaultplain) plus API plumbing and/metaexposure. - Adds shared Markdown parsing utilities with paste-specific link flattening and image detection + tests.
- Updates UI/docs/i18n to support switching editors on create and rendering/copy/source toggles on read; adds operator warning for images in legal editor too.
File summaries
| File | Description |
|---|---|
| ROADMAP.md | Removes “waiting on upstream” note now that Markdown support is implemented. |
| apps/docs/content/3.using-shhh/1.creating-a-paste.md | Documents the new opt-in Markdown formatting and its tradeoffs (copying source, links, images). |
| apps/docs/content/2.self-hosting/5.legal-pages.md | Clarifies image behavior and adds warning semantics for legal markdown. |
| apps/app/tests/markdown.test.ts | Adds comprehensive tests for shared markdown parsing, link flattening, images, and security behaviors. |
| apps/app/tests/legal-markdown.test.ts | Removes superseded tests (parser moved/renamed). |
| apps/app/tests/editor-roundtrip.test.ts | Updates roundtrip test to use the new document markdown parser. |
| apps/app/shared/utils/markdown.ts | New shared markdown parser utilities (parseDocumentMarkdown, parsePasteMarkdown, containsMarkdownImage). |
| apps/app/shared/utils/legal-markdown.ts | Removes old, legal-only markdown parser (superseded by markdown.ts). |
| apps/app/server/database/schema/paste.ts | Adds paste_format enum + format column and a constraint to keep files plain. |
| apps/app/server/database/migrations/meta/0005_snapshot.json | Migration snapshot update for the new enum/column/constraint. |
| apps/app/server/database/migrations/meta/_journal.json | Registers migration 0005_naive_tigra. |
| apps/app/server/database/migrations/0005_naive_tigra.sql | Creates paste_format, adds pastes.format with default, and adds check constraint. |
| apps/app/server/api/pastes/index.post.ts | Accepts and stores format for text pastes (default plain). |
| apps/app/server/api/pastes/[id]/reveal.post.ts | Returns format alongside ciphertext for text pastes. |
| apps/app/server/api/pastes/[id]/meta.get.ts | Exposes format pre-reveal for text pastes (files unchanged). |
| apps/app/server/api/legal/[slug].get.ts | Switches legal rendering to parseDocumentMarkdown. |
| apps/app/server/api/admin/legal/index.put.ts | Validates legal markdown via parseDocumentMarkdown before saving. |
| apps/app/i18n/locales/fr.json | Adds labels/hints for Markdown mode + copy/source/view toggles + image warning strings. |
| apps/app/i18n/locales/en.json | Adds labels/hints for Markdown mode + copy/source/view toggles + image warning strings. |
| apps/app/app/pages/p/[id].vue | Renders markdown pastes client-side (safe parser) with “view source” + “copy source” affordances; widens card. |
| apps/app/app/pages/legal/[slug].vue | Uses RichText component for legal documents. |
| apps/app/app/pages/index.vue | Adds “Format with Markdown” switch, editor swap, image warning, and sends format on create; widens card. |
| apps/app/app/pages/admin/legal.vue | Adds image-warning alert for operators editing legal markdown. |
| apps/app/app/components/TextEditor.client.vue | Adds minimum height styling so empty editor isn’t a sliver. |
| apps/app/app/components/RichTextTable.vue | New table wrapper component to match Tiptap’s table wrapper behavior. |
| apps/app/app/components/RichText.vue | Updates table component mapping to RichTextTable. |
| apps/app/app/assets/css/rich-text.css | Updates comment/reference to RichTextTable.vue. |
Review details
- Files reviewed: 26/27 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Closes #43
A switch on the creation form, off by default, turns the textarea into
TextEditorand stores the paste as markdown. The read page renders it, with the source one click away and a copy button that says it hands over the markdown source. Plain text is untouched: same field, same<pre>, same copy.Why
The same link that carries a token also carries handover notes, and those arrived as one monospace block. Issue #43 has the longer version, including the measurement that decides the shape of this: a rich text editor stores markdown, not what was typed, so
Tr0ub4dor&3_*hunter2*is stored asTr0ub4dor&3\_*hunter2*and the copy button would hand that to the recipient. Hence an option rather than a replacement, and off rather than on.Four things about the shape of it:
The format is a column, not a fragment parameter.
#key=…&f=mdwould have avoided a migration and told the server nothing, but the column survives a mangled link and lets/metasay what is coming before the reveal. Apastes_format_kind_checkconstraint keeps itplainfor a file, which is downloaded and never rendered.Links are allowed through the parser and flattened afterwards. Leaving
aout of the allowlist looked like the obvious way to keep a paste unclickable, and it is wrong: comark'ssecurityplugin drops a disallowed element with its children, so[docs](https://example.com)became an empty paragraph andsee https://example.combecamesee. Links now parse normally andflattenLinksrewrites them todocs (https://example.com), printing an autolinked URL once rather than twice. Nothing is clickable, and the destination is visible — which is what a page holding a decryption key wants, since a paste is written by anyone and read by whoever was sent the link.Images warn instead of blocking. The parser already dropped them, so nothing renders broken; the warning explains why, at creation, where it can still be acted on. It is wired into
/admin/legaltoo, which is the half of #29 that was never done — the fix stopped the broken icon, it never told the operator anything. The markdown itself is kept: a document being passed along may well be going somewhere images do work.The parser is now shared.
shared/utils/legal-markdown.tsbecomesmarkdown.tsand exportsparseDocumentMarkdown(legal pages, links clickable, the operator vouches for them) andparsePasteMarkdown(links flattened), over one security configuration.LegalDocument.vueandLegalTable.vuefollow asRichText.vueandRichTextTable.vue, since both are now used by both.Two interface adjustments ride along: the creation and read cards widen to
max-w-5xl, the width the admin pages already use, and the editor gets a minimum height so an empty one is not a sliver.Checks
pnpm lint,pnpm typecheckandpnpm testpass — 183 tests, 11 newpnpm db:generate) —0005_naive_tigra.sql, adding the enum, the column with aplaindefault and the check constraintNone of the three, but the read path is worth a careful look anyway. Nothing about encryption, key derivation, the unlock hash or the atomic counter changes — the format travels beside the ciphertext and is never derived from it. What is new is that the read page now renders attacker-supplied content on the one page that was previously inert, so the parser configuration is the thing to review:
registerDefaultPlugins: false(no raw HTML, no component syntax, no free-form attributes) and an allowlist that has never includedimg.tests/markdown.test.tscovers raw HTML,javascript:URLs, refused protocols and images for the paste parser as it already did for the document one.Worth knowing when reviewing: the migration has to be applied locally (
pnpm db:migrate) before the creation form works — generating the file does not touch the database, and the first paste otherwise fails withcolumn "format" of relation "pastes" does not exist.AI assistance
See CONTRIBUTING.md. Disclosure is not held against you: it tells
the reviewer where to look hardest.