feat(calendar): M8.3 — calendar pilot for unlisted-share end-to-end

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 11:40:53 +02:00
parent 8b9fbd2e1c
commit fbbadc91f0
13 changed files with 1093 additions and 11 deletions

View file

@ -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<Record<string, unknown>> {
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<Record<string, unknown>> {
const raw = await db.table<LocalEvent>('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<LocalTimeBlock>('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,
};
}

View file

@ -0,0 +1,230 @@
<!--
Shared-Event-View — the public render of a calendar event behind
an unlisted share link.
Whitelist: title, startTime, endTime, isAllDay, timezone, location.
That's everything the resolver put into the blob (see
apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts :: buildEventBlob).
Design goals: legible date, one-glance "when + where", trivial
"add to calendar" via .ics download, quiet CTA at the end. Works
in light + dark (via prefers-color-scheme in the layout).
-->
<script lang="ts">
interface EventBlob {
title: string;
startTime: string;
endTime: string;
isAllDay: boolean;
timezone: string | null;
location: string | null;
}
let {
blob,
token,
expiresAt,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const event = $derived(blob as unknown as EventBlob);
const start = $derived(new Date(event.startTime));
const end = $derived(new Date(event.endTime));
function formatDate(d: Date): string {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(d);
}
function formatTime(d: Date): string {
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
}).format(d);
}
const dateLabel = $derived(formatDate(start));
const timeLabel = $derived(
event.isAllDay ? 'Ganztägig' : `${formatTime(start)} ${formatTime(end)}`
);
// Same-day range = compact; otherwise show two dates
const sameDay = $derived(
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
);
const dateRangeLabel = $derived(
sameDay ? dateLabel : `${formatDate(start)} ${formatDate(end)}`
);
const icsUrl = $derived(`/share/${token}/ical`);
const ogDescription = $derived(
[event.isAllDay ? dateLabel : `${dateLabel}, ${formatTime(start)}`, event.location]
.filter(Boolean)
.join(' · ')
);
</script>
<svelte:head>
<title>{event.title} · Mana</title>
<meta name="robots" content="noindex, nofollow" />
<meta property="og:title" content={event.title} />
<meta property="og:description" content={ogDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
</svelte:head>
<article class="event">
<span class="event__kind">Termin</span>
<h1 class="event__title">{event.title}</h1>
<dl class="event__meta">
<div class="event__row">
<dt>Wann</dt>
<dd>
<div class="event__date">{dateRangeLabel}</div>
<div class="event__time">{timeLabel}</div>
{#if event.timezone}
<div class="event__tz">Zeitzone: {event.timezone}</div>
{/if}
</dd>
</div>
{#if event.location}
<div class="event__row">
<dt>Wo</dt>
<dd>{event.location}</dd>
</div>
{/if}
</dl>
<a class="event__ics" href={icsUrl} download="event.ics">📅 Zum eigenen Kalender hinzufügen</a>
{#if expiresAt}
<p class="event__expiry">
Dieser Link läuft am {new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(new Date(expiresAt))} ab.
</p>
{/if}
</article>
<style>
.event {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.event__kind {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
font-weight: 600;
}
.event__title {
margin: 0;
font-size: 2rem;
font-weight: 700;
line-height: 1.15;
}
.event__meta {
margin: 0;
padding: 1.25rem;
background: rgba(0, 0, 0, 0.03);
border-radius: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.event__row {
display: grid;
grid-template-columns: 5rem 1fr;
gap: 1rem;
align-items: baseline;
}
.event__row dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
font-weight: 600;
}
.event__row dd {
margin: 0;
}
.event__date {
font-weight: 600;
font-size: 1.0625rem;
}
.event__time {
font-size: 0.9375rem;
color: #374151;
margin-top: 0.15rem;
}
.event__tz {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}
.event__ics {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
background: #4f46e5;
color: white;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
font-size: 0.9375rem;
align-self: flex-start;
}
.event__ics:hover {
background: #4338ca;
}
.event__expiry {
font-size: 0.8125rem;
color: #6b7280;
margin: 0;
font-style: italic;
}
@media (prefers-color-scheme: dark) {
.event__kind {
color: #9ca3af;
}
.event__meta {
background: rgba(255, 255, 255, 0.05);
}
.event__row dt {
color: #9ca3af;
}
.event__time {
color: #d1d5db;
}
.event__tz {
color: #9ca3af;
}
.event__ics {
background: #818cf8;
}
.event__ics:hover {
background: #6366f1;
}
.event__expiry {
color: #9ca3af;
}
}
</style>

View file

@ -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 @@
</div>
</div>
<!-- Share link (only when visibility = unlisted) -->
{#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl}
<div class="detail-row">
<span class="detail-label">Link</span>
<div class="detail-content">
<SharedLinkControls
token={event.unlistedToken}
url={shareUrl}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
/>
</div>
</div>
{/if}
<!-- Time -->
<div class="detail-row">
<span class="detail-icon"><Clock size={18} /></span>

View file

@ -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<LocalEvent>('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<LocalEvent>('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);
}
},
};

View file

@ -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,

View file

@ -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 @@
<VisibilityPicker level={event.visibility ?? 'private'} onChange={handleVisibilityChange} />
</div>
{#if event.visibility === 'unlisted' && event.unlistedToken && shareUrl}
<div class="prop-row prop-row--share">
<SharedLinkControls
token={event.unlistedToken}
url={shareUrl}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
/>
</div>
{/if}
<div class="prop-row">
<span class="prop-icon"><Clock size={14} /></span>
<div class="time-fields">

View file

@ -0,0 +1,71 @@
<!--
Share-Link layout — minimal chrome for anonymous visitors.
No app nav, no auth, no sidebar. Just the snapshot + a quiet
"shared via Mana" footer + a CTA for signup.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="share-shell">
<main class="share-main">
{@render children()}
</main>
<footer class="share-footer">
<span class="share-footer__meta">Geteilt via Mana</span>
<a class="share-footer__cta" href="https://mana.how" target="_blank" rel="noopener noreferrer">
mana.how →
</a>
</footer>
</div>
<style>
.share-shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
background: #fafafa;
color: #111;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, sans-serif;
}
.share-main {
flex: 1;
max-width: 640px;
margin: 0 auto;
width: 100%;
padding: 2.5rem 1.5rem;
}
.share-footer {
padding: 1.5rem;
text-align: center;
border-top: 1px solid rgba(0, 0, 0, 0.08);
font-size: 0.8125rem;
color: #6b7280;
display: flex;
justify-content: center;
gap: 0.5rem;
}
.share-footer__cta {
color: #4f46e5;
text-decoration: none;
}
.share-footer__cta:hover {
text-decoration: underline;
}
@media (prefers-color-scheme: dark) {
.share-shell {
background: #0f1216;
color: #f4f4f5;
}
.share-footer {
color: #9ca3af;
border-top-color: rgba(255, 255, 255, 0.08);
}
.share-footer__cta {
color: #818cf8;
}
}
</style>

View file

@ -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<string, unknown>;
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;
};

View file

@ -0,0 +1,35 @@
<!--
Share-Link dispatcher. Picks the per-collection render component.
All data lives on `data.blob` — whatever the resolver put there is
what the view consumes, no Dexie roundtrips.
-->
<script lang="ts">
import SharedEventView from '$lib/modules/calendar/SharedEventView.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
{#if data.collection === 'events'}
<SharedEventView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else}
<div class="unknown">
<h1>Unbekannter Link-Typ</h1>
<p>Diese Art von geteiltem Inhalt wird von dieser Mana-Version nicht unterstützt.</p>
</div>
{/if}
<style>
.unknown {
text-align: center;
padding: 3rem 1rem;
}
.unknown h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
.unknown p {
margin: 0;
color: #6b7280;
}
</style>

View file

@ -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<string, unknown>;
}
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');
}