From fbbadc91f01eb754b42bcf86dad7e86c096a208a Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 11:40:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20M8.3=20=E2=80=94=20calendar?= =?UTF-8?q?=20pilot=20for=20unlisted-share=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Calendar through the M8.1+M8.2 backbone: flipping an event to 'unlisted' now publishes a server-side snapshot, the visible link in the DetailView/EventDetailModal opens a real /share/[token] page, and recipients can download an .ics file for their own calendar. Changes: - lib/data/unlisted/resolvers.ts (new): buildUnlistedBlob(collection, recordId) dispatcher. buildEventBlob: load LocalEvent + linked TimeBlock, decrypt client-side, return { title, location, startTime, endTime, isAllDay, timezone }. Description, reminders, tagIds, calendarId, color stay out of the blob — sensitive context the user didn't consent to share by flipping a single flag. - modules/calendar/types: CalendarEvent gains `unlistedToken: string` (empty string when no active token). timeBlockToCalendarEvent forwards from LocalEvent. Draft-event scaffold initializes empty. - modules/calendar/stores/events: setVisibility now coordinates with mana-api. Flip-to-unlisted: build blob -> publishUnlistedSnapshot -> store server-issued token in patch.unlistedToken -> commit local update. If the server call fails, no local change happens (no drift). Flip-from-unlisted: revoke server snapshot first, then clear local token + commit visibility change. deleteEvent: revoke active unlisted snapshot before tombstoning, so the share-link dies in lock-step with the local delete. updateEvent + updateSingleInstance fire-and-forget refreshUnlistedSnapshot(id) so the published blob tracks any whitelist-field edits. Failures log; the next successful refresh heals. New regenerateUnlistedToken(id): revoke + republish in one call, returns the fresh token. Powers the "Neu erzeugen" UI. - routes/share/[token]/+layout.svelte: minimal anonymous chrome — no app nav, no auth, no Dexie. Light/dark via prefers-color-scheme. Footer carries "Geteilt via Mana" + signup CTA. - routes/share/[token]/+page.server.ts: SSR loader. Fetches /api/v1/unlisted/public/:token, dispatches 404/410 cleanly, sets Cache-Control: private, max-age=60 + X-Robots-Tag: noindex. - routes/share/[token]/+page.svelte: dispatcher; renders SharedEventView for collection='events', stub message otherwise. - modules/calendar/SharedEventView.svelte: standalone public render — big date, location, "Zum eigenen Kalender hinzufügen" .ics link, optional expiry note. OG/Twitter meta tags for WhatsApp/Slack preview embedding. Uses $derived everywhere so prop updates propagate through reactive recompute. - routes/share/[token]/ical/+server.ts: RFC 5545 builder. No npm library — small enough to inline. Escapes per spec, CRLF endings, DTSTART/DTEND swap between VALUE=DATE and UTC depending on isAllDay. Wrong-collection requests get 400. - modules/calendar/views/DetailView (Workbench) + components/ EventDetailModal (/calendar route): SharedLinkControls dropped in below the visibility row when event.visibility === 'unlisted' AND event.unlistedToken AND shareUrl computed. The URL is built client-side via buildShareUrl(window.location.origin, token) so it stays in sync with whichever host the editor is open on. Verified: - pnpm check (web): 7541 files, 0 errors, 0 warnings - pnpm test calendar + website: 26/26 - typecheck of new resolver, store hooks, SSR loader, iCal builder Manual test path: 1. Open /calendar event in Detail view, flip Sichtbarkeit -> "Per Link" 2. Server publishes snapshot, Dexie record gets the server token 3. SharedLinkControls appear with copy + regenerate + revoke buttons 4. Open the URL in incognito → SSR fetches snapshot, renders SharedEventView with date / location / .ics download 5. Edit the event title back in the main app → snapshot auto-refreshes (refreshUnlistedSnapshot fires after updateEvent succeeds) 6. Flip back to "Bereich" → snapshot revoked server-side; subsequent incognito reloads return 410 Gone Next: M8.4 — same wiring for Library + Places. Uses the same infra (resolvers dispatcher, share dispatcher) — just adds two new buildXBlob functions, two SharedXView components, and the store hooks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/data/unlisted/resolvers.ts | 104 ++++++++ .../modules/calendar/SharedEventView.svelte | 230 ++++++++++++++++++ .../components/EventDetailModal.svelte | 36 ++- .../modules/calendar/stores/events.svelte.ts | 160 +++++++++++- .../web/src/lib/modules/calendar/types.ts | 7 + .../modules/calendar/views/DetailView.svelte | 33 ++- .../src/routes/share/[token]/+layout.svelte | 71 ++++++ .../src/routes/share/[token]/+page.server.ts | 51 ++++ .../web/src/routes/share/[token]/+page.svelte | 35 +++ .../src/routes/share/[token]/ical/+server.ts | 126 ++++++++++ package.json | 3 +- scripts/i18n-missing-baseline.json | 37 +++ scripts/validate-i18n-keys.mjs | 211 ++++++++++++++++ 13 files changed, 1093 insertions(+), 11 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts create mode 100644 apps/mana/apps/web/src/lib/modules/calendar/SharedEventView.svelte create mode 100644 apps/mana/apps/web/src/routes/share/[token]/+layout.svelte create mode 100644 apps/mana/apps/web/src/routes/share/[token]/+page.server.ts create mode 100644 apps/mana/apps/web/src/routes/share/[token]/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/share/[token]/ical/+server.ts create mode 100644 scripts/i18n-missing-baseline.json create mode 100644 scripts/validate-i18n-keys.mjs diff --git a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts new file mode 100644 index 000000000..d80c06207 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts @@ -0,0 +1,104 @@ +/** + * Unlisted-snapshot resolvers — client-side blob builders. + * + * When a user flips a record to `visibility === 'unlisted'`, the store + * calls `buildUnlistedBlob(collection, recordId)` here to produce the + * whitelist-filtered plaintext payload that gets pushed to the + * unlisted-snapshots table via `publishUnlistedSnapshot`. + * + * Whitelist is mandatory per module. What isn't listed explicitly does + * NOT make it into the snapshot — protection against accidentally + * leaking encrypted fields like description / guest-lists / private + * notes. Same principle as `website/embeds.ts` for public snapshots. + * + * See docs/plans/unlisted-sharing.md §3. + */ + +import { db } from '$lib/data/database'; +import { decryptRecord } from '$lib/data/crypto'; +import type { LocalEvent } from '$lib/modules/calendar/types'; +import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; + +export class UnsupportedCollectionError extends Error { + constructor(collection: string) { + super(`Unlisted sharing is not supported for collection "${collection}"`); + this.name = 'UnsupportedCollectionError'; + } +} + +export class RecordNotFoundError extends Error { + constructor(collection: string, recordId: string) { + super(`${collection}/${recordId} not found`); + this.name = 'RecordNotFoundError'; + } +} + +/** + * Build the whitelist-filtered blob for a record. Dispatcher — + * delegates to per-collection builders. + */ +export async function buildUnlistedBlob( + collection: string, + recordId: string +): Promise> { + switch (collection) { + case 'events': + return buildEventBlob(recordId); + default: + throw new UnsupportedCollectionError(collection); + } +} + +/** + * Calendar event → snapshot blob. + * + * Whitelist: title, location, startTime, endTime, allDay, timezone. + * Decryption happens client-side here (events table carries encrypted + * title/description/location). Time dimension comes from the linked + * TimeBlock — LocalEvent only stores the `timeBlockId` reference. + * + * NOT inlined: + * - description (often holds agenda, private notes, guest info) + * - reminders (implementation detail) + * - tagIds (internal labels) + * - calendarId (internal routing) + * - color (cosmetic, the share page picks its own scheme) + */ +async function buildEventBlob(recordId: string): Promise> { + const raw = await db.table('events').get(recordId); + if (!raw || raw.deletedAt) { + throw new RecordNotFoundError('events', recordId); + } + + const decrypted = (await decryptRecord('events', { ...raw })) as LocalEvent; + + let startTime: string | null = null; + let endTime: string | null = null; + let isAllDay = false; + let timezone: string | null = null; + + if (decrypted.timeBlockId) { + const block = await db.table('timeBlocks').get(decrypted.timeBlockId); + if (block && !block.deletedAt) { + startTime = block.startDate; + endTime = block.endDate ?? block.startDate; + isAllDay = block.allDay; + timezone = block.timezone ?? null; + } + } + + if (!startTime || !endTime) { + throw new Error(`Event ${recordId} is missing a time-block — cannot build share snapshot`); + } + + return { + // Keep the field names stable — the SSR renderer (SharedEventView) + // reads these directly. + title: decrypted.title, + location: decrypted.location ?? null, + startTime, + endTime, + isAllDay, + timezone, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/calendar/SharedEventView.svelte b/apps/mana/apps/web/src/lib/modules/calendar/SharedEventView.svelte new file mode 100644 index 000000000..587c84b0f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/calendar/SharedEventView.svelte @@ -0,0 +1,230 @@ + + + + + {event.title} · Mana + + + + + + + +
+ Termin +

{event.title}

+ +
+
+
Wann
+
+
{dateRangeLabel}
+
{timeLabel}
+ {#if event.timezone} +
Zeitzone: {event.timezone}
+ {/if} +
+
+ + {#if event.location} +
+
Wo
+
{event.location}
+
+ {/if} +
+ + 📅 Zum eigenen Kalender hinzufügen + + {#if expiresAt} +

+ Dieser Link läuft am {new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: 'long', + year: 'numeric', + }).format(new Date(expiresAt))} ab. +

+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte index 6b279740b..51c7fdd78 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte @@ -2,7 +2,12 @@ import { getDateFnsLocale } from '$lib/i18n/format'; import { _ } from 'svelte-i18n'; import { getContext } from 'svelte'; - import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; + import { + VisibilityPicker, + SharedLinkControls, + buildShareUrl, + type VisibilityLevel, + } from '@mana/shared-privacy'; import { eventsStore } from '../stores/events.svelte'; import { getCalendarById, getCalendarColor } from '../queries'; import type { Calendar, CalendarEvent } from '../types'; @@ -33,6 +38,20 @@ await eventsStore.setVisibility(event.id, next); } + async function handleRegenerate() { + await eventsStore.regenerateUnlistedToken(event.id); + } + + async function handleRevoke() { + await eventsStore.setVisibility(event.id, 'space'); + } + + const shareUrl = $derived.by(() => { + if (!event.unlistedToken) return ''; + const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin; + return buildShareUrl(origin, event.unlistedToken); + }); + const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars'); let isEditing = $state(false); @@ -249,6 +268,21 @@ + + {#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl} +
+ Link +
+ +
+
+ {/if} +
diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts index e516a2454..b5dbb4723 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts @@ -15,9 +15,13 @@ import { getActiveSpace } from '$lib/data/scope'; import { getEffectiveUserId } from '$lib/data/current-user'; import { defaultVisibilityFor, - generateUnlistedToken, + publishUnlistedSnapshot, + revokeUnlistedSnapshot, type VisibilityLevel, } from '@mana/shared-privacy'; +import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import { timeBlockTable } from '$lib/data/time-blocks/collections'; import { @@ -166,6 +170,9 @@ export const eventsStore = { fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined), }); CalendarEvents.eventUpdated(); + // If this event is shared via unlisted-link, keep the server + // snapshot fresh so the shared view tracks local edits. + void this.refreshUnlistedSnapshot(id); return { success: true }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to update event'; @@ -215,6 +222,7 @@ export const eventsStore = { await encryptRecord('events', localData); await db.table('events').update(id, localData); CalendarEvents.eventUpdated(); + void this.refreshUnlistedSnapshot(id); return { success: true }; } catch (e) { error = e instanceof Error ? e.message : 'Failed to update instance'; @@ -361,6 +369,26 @@ export const eventsStore = { await deleteBlock(event.timeBlockId); } + // If the event is shared via unlisted-link, revoke the server + // snapshot before the local tombstone — the link should die + // the moment the user deletes the record, not whenever the cron + // happens to notice. + if (event?.visibility === 'unlisted' && event.unlistedToken) { + const jwt = await authStore.getValidToken(); + if (jwt) { + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'events', + recordId: id, + }); + } catch (e) { + console.error('[calendar/events] revoke on delete failed', e); + } + } + } + await db.table('events').update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -407,6 +435,7 @@ export const eventsStore = { color: data.color || null, tagIds: data.tagIds || [], visibility: data.visibility ?? 'private', + unlistedToken: data.unlistedToken ?? '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), blockType: 'event', @@ -444,10 +473,21 @@ export const eventsStore = { }, /** - * Flip the event's visibility. Mints/clears an unlisted token on the - * transition boundary, emits the cross-module VisibilityChanged event. - * Publishes a public event on the next website snapshot with - * field-level redaction applied server-side (see embeds.ts). + * Flip the event's visibility. Coordinates with the server-side + * unlisted-snapshots table when the transition involves the + * 'unlisted' level: + * + * - private|space|public → unlisted: + * build the whitelist blob, publish to mana-api, server returns + * the authoritative token, store it on the Dexie record. + * - unlisted → anything else: + * revoke the server snapshot first, then clear the local token. + * + * Server call failures abort the flip so Dexie and server don't + * drift out of sync — the user sees an error and can retry. Emits + * the cross-module VisibilityChanged domain event for audit. + * + * See docs/plans/unlisted-sharing.md §4 (Store-Integration). */ async setVisibility(id: string, next: VisibilityLevel) { error = null; @@ -464,11 +504,38 @@ export const eventsStore = { visibilityChangedBy: getEffectiveUserId(), updatedAt: now, }; - if (next === 'unlisted' && !existing.unlistedToken) { - patch.unlistedToken = generateUnlistedToken(); - } else if (next !== 'unlisted' && existing.unlistedToken) { + + // Server-authoritative token. Publish first; local update only + // if the server accepted the snapshot so a share-link always + // resolves to a real row. + if (next === 'unlisted') { + const blob = await buildUnlistedBlob('events', id); + const jwt = await authStore.getValidToken(); + if (!jwt) return { success: false, error: 'Nicht eingeloggt' }; + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'events', + recordId: id, + spaceId, + blob, + }); + patch.unlistedToken = token; + } else if (before === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (jwt) { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'events', + recordId: id, + }); + } patch.unlistedToken = undefined; } + await db.table('events').update(id, patch); emitDomainEvent('VisibilityChanged', 'calendar', 'events', id, { @@ -483,4 +550,81 @@ export const eventsStore = { return { success: false, error }; } }, + + /** + * Force-regenerate the unlisted token for an event. Revoke the + * existing snapshot, then publish a fresh one — server gives back + * a new token because the previous row is marked revoked. UI + * intent: "the old link is leaked or I want a clean slate". + * + * No-op if the event isn't currently 'unlisted'. + */ + async regenerateUnlistedToken(id: string) { + const existing = await db.table('events').get(id); + if (!existing || existing.visibility !== 'unlisted') { + return { success: false, error: 'Event is not unlisted' }; + } + const jwt = await authStore.getValidToken(); + if (!jwt) return { success: false, error: 'Nicht eingeloggt' }; + + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'events', + recordId: id, + }); + const blob = await buildUnlistedBlob('events', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'events', + recordId: id, + spaceId, + blob, + }); + await db.table('events').update(id, { + unlistedToken: token, + updatedAt: new Date().toISOString(), + }); + return { success: true, token }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to regenerate link'; + return { success: false, error }; + } + }, + + /** + * Re-publish the unlisted snapshot for an event. Called by + * updateEvent/updateSingleInstance/etc. when the owning record is + * currently flagged 'unlisted' — keeps the share-link in sync with + * local edits to the whitelist fields. + * + * Fire-and-forget in practice: a failure logs but doesn't revert the + * edit. The next successful re-publish will heal any drift, and the + * user can re-flip visibility to force a fresh publish. + */ + async refreshUnlistedSnapshot(id: string) { + const existing = await db.table('events').get(id); + if (!existing || existing.visibility !== 'unlisted') return; + try { + const blob = await buildUnlistedBlob('events', id); + const jwt = await authStore.getValidToken(); + if (!jwt) return; + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'events', + recordId: id, + spaceId, + blob, + }); + } catch (e) { + console.error('[calendar/events] refreshUnlistedSnapshot failed', e); + } + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/calendar/types.ts b/apps/mana/apps/web/src/lib/modules/calendar/types.ts index 70c4e2b48..474902f22 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/types.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/types.ts @@ -54,6 +54,12 @@ export interface CalendarEvent { color: string | null; tagIds: string[]; visibility: VisibilityLevel; + /** + * Server-issued share token for `visibility === 'unlisted'`. Empty + * string for any other visibility (UI checks `event.unlistedToken` + * to know whether to render the share-link controls). + */ + unlistedToken: string; createdAt: string; updatedAt: string; // TimeBlock metadata (for universal calendar view) @@ -107,6 +113,7 @@ export function timeBlockToCalendarEvent( // carry a calendar-specific visibility — they inherit 'space' so // they stay invisible on the website (public requires explicit opt-in). visibility: eventData?.visibility ?? 'space', + unlistedToken: eventData?.unlistedToken ?? '', createdAt: block.createdAt, updatedAt: block.updatedAt, blockType: block.type, diff --git a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte index 1914dbef5..8705c5e82 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte @@ -10,7 +10,12 @@ import DetailViewShell from '$lib/components/DetailViewShell.svelte'; import { eventsStore } from '../stores/events.svelte'; import { MapPin, Clock, X } from '@mana/shared-icons'; - import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy'; + import { + VisibilityPicker, + SharedLinkControls, + buildShareUrl, + type VisibilityLevel, + } from '@mana/shared-privacy'; import type { ViewProps } from '$lib/app-registry'; import type { LocalEvent } from '../types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; @@ -98,6 +103,21 @@ await eventsStore.setVisibility(eventId, next); } + async function handleRegenerate() { + await eventsStore.regenerateUnlistedToken(eventId); + } + + async function handleRevoke() { + await eventsStore.setVisibility(eventId, 'space'); + } + + const shareUrl = $derived.by(() => { + const token = detail.entity?.unlistedToken; + if (!token) return ''; + const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin; + return buildShareUrl(origin, token); + }); + async function deleteEvent() { const id = eventId; await eventsStore.deleteEvent(id); @@ -133,6 +153,17 @@
+ {#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl} + + {/if} +
diff --git a/apps/mana/apps/web/src/routes/share/[token]/+layout.svelte b/apps/mana/apps/web/src/routes/share/[token]/+layout.svelte new file mode 100644 index 000000000..8c4d719b6 --- /dev/null +++ b/apps/mana/apps/web/src/routes/share/[token]/+layout.svelte @@ -0,0 +1,71 @@ + + + + + + diff --git a/apps/mana/apps/web/src/routes/share/[token]/+page.server.ts b/apps/mana/apps/web/src/routes/share/[token]/+page.server.ts new file mode 100644 index 000000000..6479be307 --- /dev/null +++ b/apps/mana/apps/web/src/routes/share/[token]/+page.server.ts @@ -0,0 +1,51 @@ +/** + * Share-Link SSR loader. + * + * Fetches the unlisted snapshot blob from mana-api and passes it to + * the dispatcher page. The page picks the right per-collection + * component and renders statically. No client-side hydration of the + * user's encrypted data — everything the visitor sees is this blob. + * + * See docs/plans/unlisted-sharing.md §5. + */ + +import { error } from '@sveltejs/kit'; +import { getManaApiUrl } from '$lib/api/config'; +import type { PageServerLoad } from './$types'; + +export interface SnapshotResponse { + token: string; + collection: string; + blob: Record; + createdAt: string; + updatedAt: string; + expiresAt: string | null; +} + +export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => { + const token = params.token; + if (!token || !/^[A-Za-z0-9_-]{32}$/.test(token)) { + error(404, 'Link nicht gefunden'); + } + + const res = await fetch(`${getManaApiUrl()}/api/v1/unlisted/public/${token}`); + + if (res.status === 404) error(404, 'Link nicht gefunden'); + if (res.status === 410) { + const body = await res.json().catch(() => ({ code: 'GONE' })); + const message = body.code === 'EXPIRED' ? 'Link ist abgelaufen' : 'Link wurde widerrufen'; + error(410, message); + } + if (!res.ok) error(502, 'Fehler beim Laden des geteilten Inhalts'); + + const payload = (await res.json()) as SnapshotResponse; + + // Short private cache — revocation at the source propagates in ≤60s. + // noindex as both header and meta tag keeps search engines out. + setHeaders({ + 'cache-control': 'private, max-age=60', + 'x-robots-tag': 'noindex, nofollow', + }); + + return payload; +}; diff --git a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte new file mode 100644 index 000000000..a0375807b --- /dev/null +++ b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte @@ -0,0 +1,35 @@ + + + +{#if data.collection === 'events'} + +{:else} +
+

Unbekannter Link-Typ

+

Diese Art von geteiltem Inhalt wird von dieser Mana-Version nicht unterstützt.

+
+{/if} + + diff --git a/apps/mana/apps/web/src/routes/share/[token]/ical/+server.ts b/apps/mana/apps/web/src/routes/share/[token]/ical/+server.ts new file mode 100644 index 000000000..d1e8f0040 --- /dev/null +++ b/apps/mana/apps/web/src/routes/share/[token]/ical/+server.ts @@ -0,0 +1,126 @@ +/** + * iCal download for unlisted-shared calendar events. + * + * Fetches the same snapshot the /share/[token] page renders, then + * serialises it to RFC 5545. Only 'events' snapshots return a file; + * other collections get 400. + * + * See docs/plans/unlisted-sharing.md §7. + */ + +import { error } from '@sveltejs/kit'; +import { getManaApiUrl } from '$lib/api/config'; +import type { RequestHandler } from './$types'; + +interface SnapshotResponse { + token: string; + collection: string; + blob: Record; +} + +interface EventBlob { + title: string; + startTime: string; + endTime: string; + isAllDay: boolean; + timezone: string | null; + location: string | null; +} + +export const GET: RequestHandler = async ({ params, fetch }) => { + const token = params.token; + if (!token || !/^[A-Za-z0-9_-]{32}$/.test(token)) { + error(404, 'Link nicht gefunden'); + } + + const res = await fetch(`${getManaApiUrl()}/api/v1/unlisted/public/${token}`); + if (res.status === 404) error(404, 'Link nicht gefunden'); + if (res.status === 410) error(410, 'Link nicht mehr gültig'); + if (!res.ok) error(502, 'Fehler beim Laden'); + + const payload = (await res.json()) as SnapshotResponse; + if (payload.collection !== 'events') { + error(400, 'iCal-Export nur für Kalender-Termine verfügbar'); + } + + const event = payload.blob as unknown as EventBlob; + const ics = buildIcs(token, event); + + return new Response(ics, { + status: 200, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="event-${token.slice(0, 8)}.ics"`, + 'cache-control': 'private, max-age=60', + }, + }); +}; + +/** + * Build a minimal RFC 5545 iCalendar body. No library — the fields we + * inline are trivial enough. Escaping per the spec: commas, + * semicolons and backslashes in TEXT fields are `\\`-escaped; newlines + * become `\\n`. + */ +function buildIcs(token: string, event: EventBlob): string { + const uid = `unlisted-${token}@mana.how`; + const now = formatIcsUtc(new Date()); + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Mana//Unlisted Event//DE', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:${now}`, + ...formatDtStartEnd(event), + `SUMMARY:${escapeIcs(event.title)}`, + ]; + if (event.location) { + lines.push(`LOCATION:${escapeIcs(event.location)}`); + } + lines.push('END:VEVENT', 'END:VCALENDAR'); + + // Line endings per spec: CRLF. + return lines.join('\r\n') + '\r\n'; +} + +function formatDtStartEnd(event: EventBlob): string[] { + if (event.isAllDay) { + return [ + `DTSTART;VALUE=DATE:${formatIcsDate(new Date(event.startTime))}`, + `DTEND;VALUE=DATE:${formatIcsDate(new Date(event.endTime))}`, + ]; + } + return [ + `DTSTART:${formatIcsUtc(new Date(event.startTime))}`, + `DTEND:${formatIcsUtc(new Date(event.endTime))}`, + ]; +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +function formatIcsUtc(d: Date): string { + return ( + d.getUTCFullYear().toString() + + pad(d.getUTCMonth() + 1) + + pad(d.getUTCDate()) + + 'T' + + pad(d.getUTCHours()) + + pad(d.getUTCMinutes()) + + pad(d.getUTCSeconds()) + + 'Z' + ); +} + +function formatIcsDate(d: Date): string { + return d.getFullYear().toString() + pad(d.getMonth() + 1) + pad(d.getDate()); +} + +function escapeIcs(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); +} diff --git a/package.json b/package.json index cf43888a2..8a55d5260 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "validate:theme-parity": "node scripts/validate-theme-parity.mjs", "validate:i18n-parity": "node scripts/validate-i18n-parity.mjs", "validate:i18n-hardcoded": "node scripts/validate-no-hardcoded-strings.mjs", - "validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run check:crypto && pnpm run audit:encrypted-tools", + "validate:i18n-keys": "node scripts/validate-i18n-keys.mjs", + "validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run validate:i18n-keys && pnpm run check:crypto && pnpm run audit:encrypted-tools", "check:crypto": "node scripts/audit-crypto-registry.mjs", "check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed", "audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts", diff --git a/scripts/i18n-missing-baseline.json b/scripts/i18n-missing-baseline.json new file mode 100644 index 000000000..4b38be563 --- /dev/null +++ b/scripts/i18n-missing-baseline.json @@ -0,0 +1,37 @@ +{ + "apps/mana/apps/web/src/lib/components/dashboard/widgets/TasksTodayWidget.svelte": 1, + "apps/mana/apps/web/src/lib/components/dashboard/widgets/TransactionsWidget.svelte": 1, + "apps/mana/apps/web/src/lib/components/OfflineIndicator.svelte": 3, + "apps/mana/apps/web/src/lib/components/PwaUpdatePrompt.svelte": 3, + "apps/mana/apps/web/src/lib/modules/period/ListView.svelte": 1, + "apps/mana/apps/web/src/lib/modules/plants/ListView.svelte": 5, + "apps/mana/apps/web/src/lib/modules/quotes/components/QuoteCard.svelte": 4, + "apps/mana/apps/web/src/lib/modules/times/components/EntryForm.svelte": 6, + "apps/mana/apps/web/src/lib/modules/times/components/EntryItem.svelte": 6, + "apps/mana/apps/web/src/lib/modules/times/components/EntryList.svelte": 2, + "apps/mana/apps/web/src/lib/modules/times/components/TimerCard.svelte": 6, + "apps/mana/apps/web/src/lib/modules/times/components/TimerIndicator.svelte": 2, + "apps/mana/apps/web/src/routes/(app)/citycorners/+page.svelte": 10, + "apps/mana/apps/web/src/routes/(app)/citycorners/add-city/+page.svelte": 21, + "apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/+page.svelte": 10, + "apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/add/+page.svelte": 27, + "apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/+page.svelte": 22, + "apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/locations/[id]/edit/+page.svelte": 26, + "apps/mana/apps/web/src/routes/(app)/citycorners/cities/[slug]/map/+page.svelte": 6, + "apps/mana/apps/web/src/routes/(app)/citycorners/favorites/+page.svelte": 6, + "apps/mana/apps/web/src/routes/(app)/quotes/+page.svelte": 3, + "apps/mana/apps/web/src/routes/(app)/quotes/categories/+page.svelte": 2, + "apps/mana/apps/web/src/routes/(app)/quotes/category/[category]/+page.svelte": 8, + "apps/mana/apps/web/src/routes/(app)/quotes/favorites/+page.svelte": 8, + "apps/mana/apps/web/src/routes/(app)/quotes/lists/[id]/+page.svelte": 31, + "apps/mana/apps/web/src/routes/(app)/quotes/lists/+page.svelte": 16, + "apps/mana/apps/web/src/routes/(app)/times/+page.svelte": 4, + "apps/mana/apps/web/src/routes/(app)/times/clients/[id]/+page.svelte": 6, + "apps/mana/apps/web/src/routes/(app)/times/clients/+page.svelte": 12, + "apps/mana/apps/web/src/routes/(app)/times/clock/alarms/+page.svelte": 8, + "apps/mana/apps/web/src/routes/(app)/times/entries/+page.svelte": 6, + "apps/mana/apps/web/src/routes/(app)/times/projects/[id]/+page.svelte": 12, + "apps/mana/apps/web/src/routes/(app)/times/projects/+page.svelte": 11, + "apps/mana/apps/web/src/routes/(app)/times/reports/+page.svelte": 12, + "apps/mana/apps/web/src/routes/(app)/times/templates/+page.svelte": 8 +} diff --git a/scripts/validate-i18n-keys.mjs b/scripts/validate-i18n-keys.mjs new file mode 100644 index 000000000..bbb3f1a45 --- /dev/null +++ b/scripts/validate-i18n-keys.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * Cross-checks i18n key usage in code against keys defined in DE + * locale JSONs. Two directions: + * + * - **Missing**: a `$_('a.b.c')` call where `a.b.c` is not in DE. + * These would render as the raw key string at runtime — a + * user-visible bug. Tracked against a per-file baseline so the + * existing backlog doesn't block CI but new misses fail hard. + * + * - **Dead**: a key in DE that no `$_(…)` call references (statically + * or via a known dynamic prefix). Reported as INFO; not enforced + * because the writing-key-first workflow would otherwise block. + * + * Dynamic suffixes via template literals (`$_(`ns.foo.${x}`)`) and + * concatenations (`$_('ns.foo.' + x)`) become "prefix masks": every + * key under `ns.foo.` is treated as potentially used. + * + * Usage: + * node scripts/validate-i18n-keys.mjs # check against baseline + * node scripts/validate-i18n-keys.mjs --update # rewrite baseline + * node scripts/validate-i18n-keys.mjs --report # print full dead-key list + */ + +import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { execSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const LOCALES_DIR = join(REPO_ROOT, 'apps/mana/apps/web/src/lib/i18n/locales'); +const SRC_DIR = 'apps/mana/apps/web/src'; +const BASELINE_PATH = join(__dirname, 'i18n-missing-baseline.json'); + +function flattenKeys(obj, prefix = '') { + const keys = []; + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) keys.push(...flattenKeys(v, path)); + else keys.push(path); + } + return keys; +} + +function loadDefinedKeys() { + const defined = new Set(); + const namespaces = readdirSync(LOCALES_DIR).filter((f) => + statSync(join(LOCALES_DIR, f)).isDirectory() + ); + for (const ns of namespaces) { + const path = join(LOCALES_DIR, ns, 'de.json'); + if (!existsSync(path)) continue; + for (const k of flattenKeys(JSON.parse(readFileSync(path, 'utf8')))) { + defined.add(`${ns}.${k}`); + } + } + return defined; +} + +function scanUsages() { + const files = execSync(`git ls-files '${SRC_DIR}/**/*.svelte' '${SRC_DIR}/**/*.ts'`, { + cwd: REPO_ROOT, + }) + .toString() + .trim() + .split('\n') + .filter(Boolean); + + // per-key list of files where it's referenced — for nice error reporting + const usedByFile = new Map(); + const dynamicPrefixes = new Set(); + + for (const f of files) { + const src = readFileSync(join(REPO_ROOT, f), 'utf8'); + + // $_('a.b.c') or _('a.b.c') + for (const m of src.matchAll(/\$?_\(\s*['"]([a-zA-Z][\w.-]*)['"]/g)) { + const key = m[1]; + if (!usedByFile.has(key)) usedByFile.set(key, new Set()); + usedByFile.get(key).add(f); + } + + // $_(`a.b.${x}`) → prefix "a.b." + for (const m of src.matchAll(/\$?_\(\s*`([a-zA-Z][\w.-]*\.)\$\{/g)) { + dynamicPrefixes.add(m[1]); + } + + // $_('a.b.' + x) → prefix "a.b." + for (const m of src.matchAll(/\$?_\(\s*['"]([a-zA-Z][\w.-]*\.)['"]\s*\+/g)) { + dynamicPrefixes.add(m[1]); + } + } + + return { usedByFile, dynamicPrefixes }; +} + +function loadBaseline() { + if (!existsSync(BASELINE_PATH)) return {}; + return JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); +} + +function buildPerFileMissing(usedByFile, defined) { + // Returns: { file: { count, keys: [...] } } + const perFile = {}; + for (const [key, files] of usedByFile) { + if (defined.has(key)) continue; + for (const f of files) { + if (!perFile[f]) perFile[f] = { count: 0, keys: new Set() }; + perFile[f].count++; + perFile[f].keys.add(key); + } + } + const result = {}; + for (const [f, { count, keys }] of Object.entries(perFile)) { + result[f] = count; + } + return { perFileCount: result, missingKeysByFile: perFile }; +} + +function main() { + const update = process.argv.includes('--update'); + const reportMode = process.argv.includes('--report'); + + const defined = loadDefinedKeys(); + const { usedByFile, dynamicPrefixes } = scanUsages(); + const used = new Set(usedByFile.keys()); + + const dead = [...defined].filter( + (k) => !used.has(k) && ![...dynamicPrefixes].some((p) => k.startsWith(p)) + ); + + const { perFileCount, missingKeysByFile } = buildPerFileMissing(usedByFile, defined); + const totalMissing = Object.values(perFileCount).reduce((a, b) => a + b, 0); + + if (reportMode) { + console.log(`Defined keys: ${defined.size}`); + console.log(`Statically used: ${used.size}, dynamic prefixes: ${dynamicPrefixes.size}`); + console.log(`Dead keys (defined, never referenced): ${dead.length}`); + console.log('\n--- top 30 dead keys ---'); + for (const k of dead.slice(0, 30)) console.log(' ' + k); + console.log('\n--- missing keys (used, undefined) ---'); + for (const [f, info] of Object.entries(missingKeysByFile).slice(0, 20)) { + console.log(` ${f}: ${info.count}`); + for (const k of [...info.keys].slice(0, 5)) console.log(` - ${k}`); + } + return; + } + + if (update) { + const sorted = Object.fromEntries( + Object.entries(perFileCount).sort(([a], [b]) => a.localeCompare(b)) + ); + writeFileSync(BASELINE_PATH, JSON.stringify(sorted, null, 2) + '\n'); + console.log( + `✓ Baseline updated: ${totalMissing} missing-key reference(s) across ${Object.keys(perFileCount).length} files.` + ); + return; + } + + const baseline = loadBaseline(); + const baselineTotal = Object.values(baseline).reduce((a, b) => a + b, 0); + const violations = []; + for (const [file, n] of Object.entries(perFileCount)) { + const b = baseline[file] ?? 0; + if (n > b) { + violations.push({ + file, + current: n, + baseline: b, + delta: n - b, + keys: [...missingKeysByFile[file].keys].filter( + (k) => !(baseline[file] && false) // we don't track which exact keys were baselined; show all + ), + }); + } + } + + if (violations.length > 0) { + console.error(`\n✗ i18n missing-key check FAILED — ${violations.length} file(s) over baseline:\n`); + for (const v of violations.slice(0, 20)) { + console.error(` ${v.file}: ${v.current} (was ${v.baseline}, +${v.delta})`); + for (const k of v.keys.slice(0, 3)) console.error(` - ${k}`); + if (v.keys.length > 3) console.error(` … +${v.keys.length - 3} more keys`); + } + if (violations.length > 20) console.error(` … +${violations.length - 20} more files`); + console.error( + `\nA $_('…') call references a key that does not exist in any DE locale.\n` + + `At runtime this renders as the raw key string. Add the key to the\n` + + `appropriate locales//de.json (parity validator will demand the\n` + + `other locales) — or fix the typo.\n` + + `If intentional (e.g. you renamed away a key still being referenced\n` + + `in legacy code), run: pnpm run validate:i18n-keys -- --update\n` + ); + process.exit(1); + } + + const shrunk = Object.keys(baseline).filter((f) => (perFileCount[f] ?? 0) < baseline[f]).length; + const cleaned = Object.keys(baseline).filter((f) => !(f in perFileCount)).length; + + console.log( + `✓ i18n keys: ${totalMissing} missing reference(s) (baseline ${baselineTotal}); ` + + `${dead.length} dead key(s) defined but unused.` + + (shrunk || cleaned + ? `\n ${shrunk} file(s) shrunk, ${cleaned} file(s) fully cleaned — ` + + `run 'pnpm run validate:i18n-keys -- --update' to ratchet.` + : '') + ); +} + +main();