Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/app/app/assets/css/rich-text.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
// `MarkdownDocument` is registered globally by the @comark/nuxt module.
import type { MarkdownDocument as MarkdownDocumentType } from 'comark'
import LegalTable from './LegalTable.vue'
import RichTextTable from './RichTextTable.vue'

defineProps<{ value: MarkdownDocumentType }>()
</script>
Expand All @@ -10,7 +10,7 @@ defineProps<{ value: MarkdownDocumentType }>()
<div class="rich-text">
<MarkdownDocument
:value="value"
:components="{ table: LegalTable }"
:components="{ table: RichTextTable }"
/>
</div>
</template>
2 changes: 1 addition & 1 deletion apps/app/app/components/TextEditor.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const tableItems = computed<EditorToolbarItem[][]>(() => [
:extensions="extensions"
:handlers="handlers"
:editor-props="editorProps"
:ui="{ base: 'rich-text p-4', content: 'relative w-full' }"
:ui="{ base: 'rich-text p-4 min-h-40', content: 'relative w-full' }"
class="overflow-y-auto rounded-md border border-default"
>
<UEditorToolbar
Expand Down
13 changes: 13 additions & 0 deletions apps/app/app/pages/admin/legal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const content = computed({
set: value => (drafts.value[draftKey.value] = value)
})

// The parser drops images and the CSP would refuse a remote one anyway, so an operator who writes one sees it vanish with nothing said. Same warning as the paste form.
const hasImage = computed(() => containsMarkdownImage(content.value))
Comment thread
thoda-dev marked this conversation as resolved.

const localeLabels = computed(() =>
Object.fromEntries(LOCALES.map(code => [code, t(`admin.legal.locales.${code}`)]))
)
Expand Down Expand Up @@ -251,6 +254,16 @@ function formatDate(value: string) {
/>
</div>

<UAlert
v-if="hasImage"
color="warning"
variant="subtle"
icon="i-lucide-image-off"
:title="t('editor.imageWarningTitle')"
:description="t('editor.imageWarningDescription')"
class="mt-3"
/>

<p
v-if="unsavedLocales.length"
class="mt-2 text-xs text-warning"
Expand Down
55 changes: 44 additions & 11 deletions apps/app/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ const kindItems = computed<TabsItem[]>(() => [
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<File | null>(null)

// An image cannot be displayed — see `containsMarkdownImage`. The markdown is kept anyway, since the content is often on its way somewhere else, so this warns rather than blocks.
const hasImage = computed(() => markdownMode.value && containsMarkdownImage(textContent.value))
Comment thread
thoda-dev marked this conversation as resolved.

const passwordProtected = ref(false)
const password = ref('')

Expand Down Expand Up @@ -125,6 +130,7 @@ async function submit() {
payload.kind = 'text'
payload.ciphertext = bytesToBase64(ciphertext)
payload.iv = bytesToBase64(iv)
payload.format = markdownMode.value ? 'markdown' : 'plain'
} else {
const selectedFile = file.value!
const { ciphertext: fileBlob, iv: fileIv } = await encryptBytes(aesKey, new Uint8Array(await selectedFile.arrayBuffer()))
Expand Down Expand Up @@ -173,6 +179,7 @@ const mailtoHref = computed(() => {
function reset() {
resultUrl.value = ''
textContent.value = ''
markdownMode.value = false
file.value = null
passwordProtected.value = false
password.value = ''
Expand All @@ -190,7 +197,7 @@ function reset() {

<template>
<div class="flex flex-1 items-center justify-center p-4">
<UCard class="w-full max-w-2xl">
<UCard class="w-full max-w-5xl">
<template #header>
<div>
<h1 class="text-xl font-semibold">
Expand Down Expand Up @@ -293,18 +300,44 @@ function reset() {
/>
</UTooltip>

<UTextarea
v-if="kind === 'text'"
v-model="textContent"
:rows="10"
autoresize
:maxrows="20"
:placeholder="t('create.textPlaceholder')"
class="w-full font-mono"
/>
<template v-if="kind === 'text'">
<div class="space-y-1">
<USwitch
v-model="markdownMode"
:label="t('create.markdownFormat')"
/>
<p class="text-xs text-muted">
{{ t('create.markdownHint') }}
</p>
</div>

<TextEditor
v-if="markdownMode"
v-model="textContent"
class="max-h-96"
/>
<UTextarea
v-else
v-model="textContent"
:rows="10"
autoresize
:maxrows="20"
:placeholder="t('create.textPlaceholder')"
class="w-full font-mono"
/>

<UAlert
v-if="hasImage"
color="warning"
variant="subtle"
icon="i-lucide-image-off"
:title="t('editor.imageWarningTitle')"
:description="t('editor.imageWarningDescription')"
/>
</template>
<!-- The default layout hides the file name behind a full-frame image preview, and puts the card under the dropzone rather than in it. -->
<UFileUpload
v-else
v-if="kind === 'file'"
v-model="file"
layout="list"
position="inside"
Expand Down
2 changes: 1 addition & 1 deletion apps/app/app/pages/legal/[slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const updatedAt = computed(() =>
class="mb-6"
/>

<LegalDocument :value="data!.document" />
<RichText :value="data!.document" />

<p class="mt-10 text-xs text-muted">
{{ t('legal.updatedAt', { date: updatedAt }) }}
Expand Down
46 changes: 39 additions & 7 deletions apps/app/app/pages/p/[id].vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<script setup lang="ts">
import type { MarkdownDocument } from 'comark'

interface MetaResponse {
kind: 'text' | 'file'
format?: 'plain' | 'markdown'
passwordProtected: boolean
readsRemaining: number | null
expiresAt: string
Expand All @@ -10,6 +13,7 @@ interface MetaResponse {

interface RevealTextResponse {
kind: 'text'
format: 'plain' | 'markdown'
passwordProtected: boolean
ciphertext: string
iv: string
Expand Down Expand Up @@ -63,6 +67,13 @@ const revealError = ref('')
const decryptedText = ref('')
const decryptedFile = ref<{ url: string, name: string } | null>(null)

// The markdown is parsed here and nowhere else: the plaintext exists only in this browser, so the
// server that renders every other document in the app cannot see this one.
const rendered = ref<MarkdownDocument | null>(null)
// The rendered document is what the creator composed, but the source is what the reader copies, so
// the way to it is always one click away.
const showSource = ref(false)

async function reveal() {
revealError.value = ''
revealing.value = true
Expand All @@ -82,6 +93,7 @@ async function reveal() {
if (data.kind === 'text') {
const plaintext = await decryptBytes(aesKey, base64ToBytes(data.ciphertext), base64ToBytes(data.iv))
decryptedText.value = new TextDecoder().decode(plaintext)
if (data.format === 'markdown') rendered.value = await parsePasteMarkdown(decryptedText.value)
} else {
Comment thread
thoda-dev marked this conversation as resolved.
const fileBytes = await decryptBytes(aesKey, base64ToBytes(data.fileBlob), base64ToBytes(data.fileIv))
const nameBytes = await decryptBytes(aesKey, base64ToBytes(data.fileNameEnc), base64ToBytes(data.fileNameIv))
Expand All @@ -107,7 +119,7 @@ async function copyText() {

<template>
<div class="flex flex-1 items-center justify-center p-4">
<UCard class="w-full max-w-2xl">
<UCard class="w-full max-w-5xl">
<template #header>
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-semibold">
Expand Down Expand Up @@ -140,13 +152,33 @@ async function copyText() {
class="space-y-4"
>
<template v-if="decryptedText">
<div
v-if="rendered && !showSource"
class="max-h-96 overflow-auto rounded-lg bg-elevated p-4"
>
<RichText :value="rendered" />
</div>
<!-- `break-words` as well as the wrap: a secret is often one long unbroken token. -->
<pre class="max-h-96 overflow-auto rounded-lg bg-elevated p-4 text-sm break-words whitespace-pre-wrap">{{ decryptedText }}</pre>
<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied ? t('create.result.copied') : t('read.copyText')"
@click="copyText"
/>
<pre
v-else
class="max-h-96 overflow-auto rounded-lg bg-elevated p-4 text-sm break-words whitespace-pre-wrap"
>{{ decryptedText }}</pre>

<div class="flex flex-wrap items-center gap-2">
<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
:label="copied ? t('create.result.copied') : (rendered ? t('read.copySource') : t('read.copyText'))"
@click="copyText"
/>
<!-- Named after what it switches to, since what is on screen is already visible. -->
<UButton
v-if="rendered"
variant="ghost"
:icon="showSource ? 'i-lucide-file-text' : 'i-lucide-file-code'"
:label="showSource ? t('read.viewRendered') : t('read.viewSource')"
@click="showSource = !showSource"
/>
</div>
</template>
<template v-else-if="decryptedFile">
<UButton
Expand Down
9 changes: 8 additions & 1 deletion apps/app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"link": "Link",
"horizontalRule": "Separator",
"insertTable": "Insert a table",
"clearFormatting": "Clear formatting"
"clearFormatting": "Clear formatting",
"imageWarningTitle": "Images will not be displayed",
"imageWarningDescription": "This instance hosts no images, and its security policy refuses remote ones. The markdown is kept in the content, so it still works wherever you paste it next — it simply shows nothing here."
},
"setup": {
"title": "Welcome to shhh",
Expand Down Expand Up @@ -82,6 +84,8 @@
"kindFile": "File",
"fileRequiresLogin": "Log in to upload files",
"textPlaceholder": "Paste your text here…",
"markdownFormat": "Format with Markdown",
"markdownHint": "For a note or a document meant to be read. A secret meant to be copied exactly as typed should stay plain text.",
"passwordProtect": "Protect with a password",
"passwordPlaceholder": "Password",
"expiresInDays": "Expires in (days)",
Expand Down Expand Up @@ -130,6 +134,9 @@
"burnWarningPassword": "A wrong password costs nothing: the read is only spent once the password is right.",
"reveal": "Reveal",
"copyText": "Copy",
"copySource": "Copy the Markdown source",
"viewSource": "View the source",
"viewRendered": "View the formatted text",
"downloadFile": "Download {name}",
"errors": {
"notFound": "This paste doesn't exist, has expired, or has already been read the maximum number of times.",
Expand Down
9 changes: 8 additions & 1 deletion apps/app/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"link": "Lien",
"horizontalRule": "Séparateur",
"insertTable": "Insérer un tableau",
"clearFormatting": "Effacer la mise en forme"
"clearFormatting": "Effacer la mise en forme",
"imageWarningTitle": "Les images ne seront pas affichées",
"imageWarningDescription": "Cette instance n'héberge aucune image et sa politique de sécurité refuse les images distantes. Le markdown est conservé dans le contenu : il fonctionnera là où vous le collerez ensuite, il n'affiche simplement rien ici."
},
"setup": {
"title": "Bienvenue sur shhh",
Expand Down Expand Up @@ -82,6 +84,8 @@
"kindFile": "Fichier",
"fileRequiresLogin": "Connectez-vous pour envoyer des fichiers",
"textPlaceholder": "Collez votre texte ici…",
"markdownFormat": "Mettre en forme avec Markdown",
"markdownHint": "Pour une note ou un document destiné à être lu. Un secret destiné à être copié tel quel doit rester en texte brut.",
"passwordProtect": "Protéger par un mot de passe",
"passwordPlaceholder": "Mot de passe",
"expiresInDays": "Expire dans (jours)",
Expand Down Expand Up @@ -130,6 +134,9 @@
"burnWarningPassword": "Un mot de passe incorrect ne coûte rien : la lecture n'est décomptée qu'avec le bon mot de passe.",
"reveal": "Révéler",
"copyText": "Copier",
"copySource": "Copier la source Markdown",
"viewSource": "Voir la source",
"viewRendered": "Voir le texte mis en forme",
"downloadFile": "Télécharger {name}",
"errors": {
"notFound": "Ce paste n'existe pas, a expiré, ou a déjà été lu le nombre maximum de fois.",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/server/api/admin/legal/index.put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
}

// Fails here rather than on the public page.
await parseLegalMarkdown(content)
await parseDocumentMarkdown(content)

Comment thread
thoda-dev marked this conversation as resolved.
const [document] = await db
.insert(schema.legalDocuments)
Expand Down
2 changes: 1 addition & 1 deletion apps/app/server/api/legal/[slug].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ export default defineEventHandler(async (event) => {
locale: document.locale,
updatedAt: document.updatedAt,
// Parsed server-side: the parser config is what keeps script out, and it stays out of reach.
document: await parseLegalMarkdown(document.content)
document: await parseDocumentMarkdown(document.content)
}
Comment thread
thoda-dev marked this conversation as resolved.
})
4 changes: 3 additions & 1 deletion apps/app/server/api/pastes/[id]/meta.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineEventHandler(async (event) => {
const [paste] = await db
.select({
kind: schema.pastes.kind,
format: schema.pastes.format,
passwordProtected: schema.pastes.passwordProtected,
maxReads: schema.pastes.maxReads,
readCount: schema.pastes.readCount,
Expand All @@ -27,6 +28,7 @@ export default defineEventHandler(async (event) => {
passwordProtected: paste.passwordProtected,
readsRemaining: paste.maxReads === null ? null : paste.maxReads - paste.readCount,
expiresAt: paste.expiresAt,
...(paste.kind === 'file' ? { fileMime: paste.fileMime, fileSize: paste.fileSize } : {})
// Before the reveal, so the page knows whether it is about to render a document or a secret.
...(paste.kind === 'text' ? { format: paste.format } : { fileMime: paste.fileMime, fileSize: paste.fileSize })
}
})
1 change: 1 addition & 0 deletions apps/app/server/api/pastes/[id]/reveal.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default defineEventHandler(async (event) => {
if (paste.kind === 'text') {
return {
kind: 'text' as const,
format: paste.format,
passwordProtected: paste.passwordProtected,
ciphertext: paste.ciphertext!.toString('base64'),
iv: paste.iv!.toString('base64')
Expand Down
3 changes: 3 additions & 0 deletions apps/app/server/api/pastes/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const textPasteSchema = z.object({
kind: z.literal('text'),
ciphertext: z.string().base64(),
iv: z.string().base64(),
// How the reader displays the plaintext. The server cannot tell — it never sees it.
format: z.enum(['plain', 'markdown']).default('plain'),
...baseFields
})

Expand Down Expand Up @@ -165,6 +167,7 @@ export default defineEventHandler(async (event) => {
}
values.ciphertext = ciphertext
values.iv = Buffer.from(body.iv, 'base64')
values.format = body.format
} else {
const fileBlob = Buffer.from(body.fileBlob, 'base64')
if (settings.max_upload_size_bytes !== null && fileBlob.length > settings.max_upload_size_bytes) {
Expand Down
3 changes: 3 additions & 0 deletions apps/app/server/database/migrations/0005_naive_tigra.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE TYPE "public"."paste_format" AS ENUM('plain', 'markdown');--> statement-breakpoint
ALTER TABLE "pastes" ADD COLUMN "format" "paste_format" DEFAULT 'plain' NOT NULL;--> statement-breakpoint
ALTER TABLE "pastes" ADD CONSTRAINT "pastes_format_kind_check" CHECK ("pastes"."format" = 'plain' OR "pastes"."kind" = 'text');
Loading