From fe9798b552413dac9baee3be6f00cae9e8685889 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:02:01 +0200 Subject: [PATCH] feat(legal): publish privacy, terms and legal notice pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three documents — privacy policy, terms of use, legal notice — written in Markdown per language, published from the admin dashboard and served at /legal/. They live in the database like every other setting: no volume to mount, nothing extra to back up. A document nobody wrote has no page and no footer link, and a read falls back across languages so a link always resolves. Rendering goes through Comark with its default plugins off: no raw HTML, no component syntax, no free-form attributes, and link protocols limited to http, https and mailto. In an app where the browser holds the only decryption key, a script tag on any page is a leaked key, not a defaced page. Templates for all three ship inside the image in English and French. Inserting one opens a form built from the template's own placeholders and fills every selected language at once — durations are a number and a unit written in each language's words, and paste retention, session lifetime and ban duration arrive prefilled from the instance's configuration. A blank field keeps its placeholder; a field switched off takes its line with it. The editor shows Markdown beside a live preview using the same parser as the public page, and one Save stores every language that has unsaved text. The footer these pages introduce pushed centred pages past 100vh, so the header moves from fixed to in-flow and the admin screens share one max width. Provisional: the responsive pass will revisit it. --- README.md | 1 + apps/app/app/app.vue | 61 +- apps/app/app/components/AdminNav.vue | 3 +- apps/app/app/components/AppFooter.vue | 23 + apps/app/app/components/LegalDocument.vue | 160 + .../app/components/LegalTemplateDialog.vue | 237 + apps/app/app/composables/usePublicSettings.ts | 3 + apps/app/app/middleware/require-2fa.global.ts | 4 +- apps/app/app/pages/account.vue | 2 +- apps/app/app/pages/admin/allowed-ips.vue | 2 +- apps/app/app/pages/admin/banned-ips.vue | 2 +- apps/app/app/pages/admin/invitations.vue | 2 +- apps/app/app/pages/admin/legal.vue | 326 ++ apps/app/app/pages/admin/settings.vue | 2 +- apps/app/app/pages/admin/storage.vue | 2 +- apps/app/app/pages/admin/users.vue | 2 +- apps/app/app/pages/dashboard.vue | 2 +- apps/app/app/pages/index.vue | 2 +- apps/app/app/pages/legal/[slug].vue | 62 + apps/app/app/pages/login.vue | 2 +- apps/app/app/pages/p/[id].vue | 2 +- apps/app/app/pages/register.vue | 2 +- apps/app/app/pages/setup.vue | 2 +- apps/app/i18n/locales/en.json | 87 + apps/app/i18n/locales/fr.json | 87 + apps/app/nuxt.config.ts | 3 +- apps/app/package.json | 23 +- apps/app/server/api/admin/legal/index.get.ts | 18 + apps/app/server/api/admin/legal/index.put.ts | 54 + .../server/api/admin/legal/template.get.ts | 19 + apps/app/server/api/legal/[slug].get.ts | 28 + apps/app/server/api/settings/public.get.ts | 6 +- apps/app/server/assets/legal/notice.en.md | 28 + apps/app/server/assets/legal/notice.fr.md | 28 + apps/app/server/assets/legal/privacy.en.md | 92 + apps/app/server/assets/legal/privacy.fr.md | 92 + apps/app/server/assets/legal/terms.en.md | 70 + apps/app/server/assets/legal/terms.fr.md | 70 + .../migrations/0004_naive_night_thrasher.sql | 11 + .../migrations/meta/0004_snapshot.json | 1337 +++++ .../database/migrations/meta/_journal.json | 7 + apps/app/server/database/schema/index.ts | 1 + .../server/database/schema/legal-document.ts | 25 + apps/app/server/utils/ip-security.ts | 13 +- apps/app/server/utils/legal.ts | 73 + apps/app/server/utils/locales.ts | 8 + apps/app/server/utils/mail-locale.ts | 15 +- apps/app/shared/utils/legal-markdown.ts | 32 + apps/app/shared/utils/legal-template.ts | 104 + apps/app/tests/legal-markdown.test.ts | 77 + apps/app/tests/legal-template.test.ts | 78 + apps/app/tests/locales.test.ts | 36 + .../content/2.self-hosting/5.legal-pages.md | 77 + apps/docs/nuxt.config.ts | 1 + package.json | 2 +- pnpm-lock.yaml | 4308 ++++++++++------- 56 files changed, 5899 insertions(+), 1917 deletions(-) create mode 100644 apps/app/app/components/AppFooter.vue create mode 100644 apps/app/app/components/LegalDocument.vue create mode 100644 apps/app/app/components/LegalTemplateDialog.vue create mode 100644 apps/app/app/pages/admin/legal.vue create mode 100644 apps/app/app/pages/legal/[slug].vue create mode 100644 apps/app/server/api/admin/legal/index.get.ts create mode 100644 apps/app/server/api/admin/legal/index.put.ts create mode 100644 apps/app/server/api/admin/legal/template.get.ts create mode 100644 apps/app/server/api/legal/[slug].get.ts create mode 100644 apps/app/server/assets/legal/notice.en.md create mode 100644 apps/app/server/assets/legal/notice.fr.md create mode 100644 apps/app/server/assets/legal/privacy.en.md create mode 100644 apps/app/server/assets/legal/privacy.fr.md create mode 100644 apps/app/server/assets/legal/terms.en.md create mode 100644 apps/app/server/assets/legal/terms.fr.md create mode 100644 apps/app/server/database/migrations/0004_naive_night_thrasher.sql create mode 100644 apps/app/server/database/migrations/meta/0004_snapshot.json create mode 100644 apps/app/server/database/schema/legal-document.ts create mode 100644 apps/app/server/utils/legal.ts create mode 100644 apps/app/server/utils/locales.ts create mode 100644 apps/app/shared/utils/legal-markdown.ts create mode 100644 apps/app/shared/utils/legal-template.ts create mode 100644 apps/app/tests/legal-markdown.test.ts create mode 100644 apps/app/tests/legal-template.test.ts create mode 100644 apps/app/tests/locales.test.ts create mode 100644 apps/docs/content/2.self-hosting/5.legal-pages.md diff --git a/README.md b/README.md index 9e8084f..28675be 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ without trusting the server with the contents. - Email sharing at creation time, one message with recipients in blind copy - Cloudflare Turnstile, per-IP and per-account rate limiting, automatic probe banning - Account deletion with full cascade (GDPR right to erasure) +- Legal pages — privacy policy, terms, legal notice — written in Markdown from the admin dashboard, with templates included - English and French ## Deploy diff --git a/apps/app/app/app.vue b/apps/app/app/app.vue index 136f315..5a7b77e 100644 --- a/apps/app/app/app.vue +++ b/apps/app/app/app.vue @@ -34,34 +34,39 @@ useHead({ diff --git a/apps/app/app/components/AdminNav.vue b/apps/app/app/components/AdminNav.vue index a76fef9..63aca90 100644 --- a/apps/app/app/components/AdminNav.vue +++ b/apps/app/app/components/AdminNav.vue @@ -18,7 +18,8 @@ const links = computed(() => { { to: '/admin/allowed-ips', icon: 'i-lucide-shield', label: t('admin.allowedIps.title') }, { to: '/admin/banned-ips', icon: 'i-lucide-ban', label: t('admin.bannedIps.title') }, { to: '/admin/users', icon: 'i-lucide-users', label: t('admin.users.title') }, - { to: '/admin/invitations', icon: 'i-lucide-mail-plus', label: t('admin.invitations.title') } + { to: '/admin/invitations', icon: 'i-lucide-mail-plus', label: t('admin.invitations.title') }, + { to: '/admin/legal', icon: 'i-lucide-scale', label: t('admin.legal.title'), superAdminOnly: true } ] return all.filter(link => !link.superAdminOnly || user.value?.role === 'super_admin') }) diff --git a/apps/app/app/components/AppFooter.vue b/apps/app/app/components/AppFooter.vue new file mode 100644 index 0000000..fabcecb --- /dev/null +++ b/apps/app/app/components/AppFooter.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/app/app/components/LegalDocument.vue b/apps/app/app/components/LegalDocument.vue new file mode 100644 index 0000000..a99dd32 --- /dev/null +++ b/apps/app/app/components/LegalDocument.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/apps/app/app/components/LegalTemplateDialog.vue b/apps/app/app/components/LegalTemplateDialog.vue new file mode 100644 index 0000000..982df6c --- /dev/null +++ b/apps/app/app/components/LegalTemplateDialog.vue @@ -0,0 +1,237 @@ + + + diff --git a/apps/app/app/composables/usePublicSettings.ts b/apps/app/app/composables/usePublicSettings.ts index f931ca0..b17005d 100644 --- a/apps/app/app/composables/usePublicSettings.ts +++ b/apps/app/app/composables/usePublicSettings.ts @@ -1,9 +1,12 @@ +export type LegalSlug = 'privacy' | 'terms' | 'notice' + export interface PublicSettings { publicPasteEnabled: boolean registrationEnabled: boolean require2fa: boolean mailEnabled: boolean maxEmailRecipients: number | null + legalDocuments: LegalSlug[] limits: { anonymous: TierLimits authenticated: TierLimits diff --git a/apps/app/app/middleware/require-2fa.global.ts b/apps/app/app/middleware/require-2fa.global.ts index 719dcc8..f9fa4fb 100644 --- a/apps/app/app/middleware/require-2fa.global.ts +++ b/apps/app/app/middleware/require-2fa.global.ts @@ -1,6 +1,6 @@ // Client half of `app_settings.require_2fa`: a non-enrolled account is pinned to /account until it enrolls. // The server half lives in `requireAdminSession` and `POST /api/pastes` — this is the UX, not the security boundary. -const ALLOWED_ROUTE_PREFIXES = ['account', 'login', 'setup'] +const ALLOWED_ROUTE_PREFIXES = ['account', 'login', 'setup', 'legal'] export default defineNuxtRouteMiddleware(async (to) => { await ensureAuthSessionLoaded() @@ -10,7 +10,7 @@ export default defineNuxtRouteMiddleware(async (to) => { const settings = await ensurePublicSettingsLoaded() if (!settings.value?.require2fa) return - // /account is where enrollment happens; /login and /setup stay reachable regardless. Matched on route name so it survives any i18n strategy. + // /account is where enrollment happens; /login, /setup and the legal pages stay reachable regardless. Matched on route name so it survives any i18n strategy. const routeName = to.name?.toString() ?? '' if (ALLOWED_ROUTE_PREFIXES.some(prefix => routeName.startsWith(prefix))) return diff --git a/apps/app/app/pages/account.vue b/apps/app/app/pages/account.vue index 615f887..4d82f03 100644 --- a/apps/app/app/pages/account.vue +++ b/apps/app/app/pages/account.vue @@ -231,7 +231,7 @@ async function deleteAccount() {