From ddb3f9e5cdae6283676456e4cb8460a4541828ae Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Wed, 8 Apr 2026 09:04:58 +0200 Subject: [PATCH] feat: shareable shopping list links with token-based guest access - Generate temporary share links (default 24h) that allow unauthenticated users to view and edit the shopping list - Share token management modal: create, copy, delete, and adjust TTL - Token auth bypasses hooks middleware for /cospend/list routes only - Guest users see only the Liste nav item, other cospend tabs are hidden - All list API endpoints accept ?token= query param as alternative auth - MongoDB TTL index auto-expires tokens Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/hooks.server.ts | 10 + src/lib/js/shoppingSync.svelte.ts | 17 +- src/lib/server/shoppingAuth.ts | 51 +++ src/models/ShoppingShareToken.ts | 20 + src/routes/api/cospend/list/+server.ts | 19 +- .../api/cospend/list/categorize/+server.ts | 7 +- .../list/categorize/override/+server.ts | 7 +- src/routes/api/cospend/list/share/+server.ts | 66 +++ src/routes/api/cospend/list/stream/+server.ts | 7 +- src/routes/cospend/+layout.svelte | 11 +- src/routes/cospend/list/+page.server.ts | 13 +- src/routes/cospend/list/+page.svelte | 376 +++++++++++++++++- 13 files changed, 573 insertions(+), 33 deletions(-) create mode 100644 src/lib/server/shoppingAuth.ts create mode 100644 src/models/ShoppingShareToken.ts create mode 100644 src/routes/api/cospend/list/share/+server.ts diff --git a/package.json b/package.json index 8a1449c..b115ec3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.8.0", + "version": "1.9.0", "private": true, "type": "module", "scripts": { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2a57655..3457655 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -39,6 +39,16 @@ async function authorization({ event, resolve }: Parameters[0]) { // Protect cospend routes and API endpoints if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/api/cospend')) { if (!session) { + // Allow share-token access to shopping list routes + const isShoppingRoute = event.url.pathname.startsWith('/cospend/list') || event.url.pathname.startsWith('/api/cospend/list'); + const shareToken = event.url.searchParams.get('token'); + if (isShoppingRoute && shareToken) { + const { validateShareToken } = await import('$lib/server/shoppingAuth'); + if (await validateShareToken(shareToken)) { + return resolve(event); + } + } + // For API routes, return 401 instead of redirecting if (event.url.pathname.startsWith('/api/cospend')) { error(401, { diff --git a/src/lib/js/shoppingSync.svelte.ts b/src/lib/js/shoppingSync.svelte.ts index c3db22e..4bc3111 100644 --- a/src/lib/js/shoppingSync.svelte.ts +++ b/src/lib/js/shoppingSync.svelte.ts @@ -31,18 +31,25 @@ export function createShoppingSync() { let items: ShoppingItem[] = $state([]); let status: SyncStatus = $state('idle'); let version = $state(0); + let shareToken: string | null = null; let eventSource: EventSource | null = null; let debounceTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let reconnectDelay = 1000; let _applying = false; + function apiUrl(path: string): string { + if (!shareToken) return path; + const sep = path.includes('?') ? '&' : '?'; + return `${path}${sep}token=${encodeURIComponent(shareToken)}`; + } + async function pushToServer() { if (_applying) return; status = 'syncing'; try { - const res = await fetch('/api/cospend/list', { + const res = await fetch(apiUrl('/api/cospend/list'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -94,7 +101,7 @@ export function createShoppingSync() { } try { - eventSource = new EventSource('/api/cospend/list/stream'); + eventSource = new EventSource(apiUrl('/api/cospend/list/stream')); eventSource.addEventListener('update', (e) => { try { @@ -141,9 +148,10 @@ export function createShoppingSync() { status = 'idle'; } - async function init() { + async function init(token?: string | null) { + if (token) shareToken = token; try { - const res = await fetch('/api/cospend/list'); + const res = await fetch(apiUrl('/api/cospend/list')); if (!res.ok) { status = 'offline'; return; @@ -201,6 +209,7 @@ export function createShoppingSync() { get items() { return items; }, get status() { return status; }, get version() { return version; }, + apiUrl, init, addItem, toggleItem, diff --git a/src/lib/server/shoppingAuth.ts b/src/lib/server/shoppingAuth.ts new file mode 100644 index 0000000..560bf39 --- /dev/null +++ b/src/lib/server/shoppingAuth.ts @@ -0,0 +1,51 @@ +/** + * Shared auth for shopping list endpoints. + * Accepts either a logged-in session or a valid share token. + */ +import { dbConnect } from '$utils/db'; +import { ShoppingShareToken } from '$models/ShoppingShareToken'; +import crypto from 'crypto'; + +/** Returns a nickname string if authorized, null otherwise */ +export async function getShoppingUser( + locals: App.Locals, + url: URL +): Promise { + // Check session first + const auth = await locals.auth(); + if (auth?.user?.nickname) return auth.user.nickname; + + // Check share token + const token = url.searchParams.get('token'); + if (!token) return null; + + await dbConnect(); + const doc = await ShoppingShareToken.findOne({ + token, + expiresAt: { $gt: new Date() } + }).lean(); + + return doc ? `guest` : null; +} + +/** Check if a share token is valid (for hooks middleware) */ +export async function validateShareToken(token: string): Promise { + await dbConnect(); + const doc = await ShoppingShareToken.findOne({ + token, + expiresAt: { $gt: new Date() } + }).lean(); + return !!doc; +} + +/** Generate a new share token (24h TTL) */ +export async function createShareToken(createdBy: string): Promise<{ token: string; expiresAt: Date }> { + await dbConnect(); + + const token = crypto.randomBytes(24).toString('base64url'); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await ShoppingShareToken.create({ token, expiresAt, createdBy }); + + return { token, expiresAt }; +} diff --git a/src/models/ShoppingShareToken.ts b/src/models/ShoppingShareToken.ts new file mode 100644 index 0000000..4021b3e --- /dev/null +++ b/src/models/ShoppingShareToken.ts @@ -0,0 +1,20 @@ +import mongoose from 'mongoose'; + +export interface IShoppingShareToken { + token: string; + expiresAt: Date; + createdBy: string; +} + +const ShoppingShareTokenSchema = new mongoose.Schema( + { + token: { type: String, required: true, unique: true, index: true }, + expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } }, + createdBy: { type: String, required: true }, + }, + { timestamps: true } +); + +export const ShoppingShareToken = + mongoose.models.ShoppingShareToken || + mongoose.model('ShoppingShareToken', ShoppingShareTokenSchema); diff --git a/src/routes/api/cospend/list/+server.ts b/src/routes/api/cospend/list/+server.ts index 8a91d6a..bbe2a5c 100644 --- a/src/routes/api/cospend/list/+server.ts +++ b/src/routes/api/cospend/list/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { dbConnect } from '$utils/db'; import { ShoppingList } from '$models/ShoppingList'; import { broadcast } from '$lib/server/shoppingSSE'; +import { getShoppingUser } from '$lib/server/shoppingAuth'; async function getOrCreateList() { let list = await ShoppingList.findOne().lean(); @@ -14,9 +15,9 @@ async function getOrCreateList() { } // GET /api/cospend/list — fetch current shopping list -export const GET: RequestHandler = async ({ locals }) => { - const auth = await locals.auth(); - if (!auth?.user?.nickname) throw error(401, 'Not logged in'); +export const GET: RequestHandler = async ({ locals, url }) => { + const user = await getShoppingUser(locals, url); + if (!user) throw error(401, 'Not logged in'); await dbConnect(); const list = await getOrCreateList(); @@ -24,9 +25,9 @@ export const GET: RequestHandler = async ({ locals }) => { }; // PUT /api/cospend/list — update shopping list with version conflict detection -export const PUT: RequestHandler = async ({ request, locals }) => { - const auth = await locals.auth(); - if (!auth?.user?.nickname) throw error(401, 'Not logged in'); +export const PUT: RequestHandler = async ({ request, locals, url }) => { + const user = await getShoppingUser(locals, url); + if (!user) throw error(401, 'Not logged in'); await dbConnect(); const data = await request.json(); @@ -59,9 +60,9 @@ export const PUT: RequestHandler = async ({ request, locals }) => { }; // DELETE /api/cospend/list — clear all items -export const DELETE: RequestHandler = async ({ locals }) => { - const auth = await locals.auth(); - if (!auth?.user?.nickname) throw error(401, 'Not logged in'); +export const DELETE: RequestHandler = async ({ locals, url }) => { + const user = await getShoppingUser(locals, url); + if (!user) throw error(401, 'Not logged in'); await dbConnect(); const existing = await getOrCreateList(); diff --git a/src/routes/api/cospend/list/categorize/+server.ts b/src/routes/api/cospend/list/categorize/+server.ts index dbddf9a..fe59b50 100644 --- a/src/routes/api/cospend/list/categorize/+server.ts +++ b/src/routes/api/cospend/list/categorize/+server.ts @@ -1,11 +1,12 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { categorizeItem } from '$lib/server/shoppingCategorizer'; +import { getShoppingUser } from '$lib/server/shoppingAuth'; // POST /api/cospend/list/categorize — categorize a shopping item by name -export const POST: RequestHandler = async ({ request, locals }) => { - const auth = await locals.auth(); - if (!auth?.user?.nickname) throw error(401, 'Not logged in'); +export const POST: RequestHandler = async ({ request, locals, url }) => { + const user = await getShoppingUser(locals, url); + if (!user) throw error(401, 'Not logged in'); const { name } = await request.json(); if (!name || typeof name !== 'string') { diff --git a/src/routes/api/cospend/list/categorize/override/+server.ts b/src/routes/api/cospend/list/categorize/override/+server.ts index 2488552..7435a0f 100644 --- a/src/routes/api/cospend/list/categorize/override/+server.ts +++ b/src/routes/api/cospend/list/categorize/override/+server.ts @@ -2,11 +2,12 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { ShoppingItemCategory } from '$models/ShoppingItemCategory'; import { dbConnect } from '$utils/db'; +import { getShoppingUser } from '$lib/server/shoppingAuth'; // POST /api/cospend/list/categorize/override — manually set category + icon for an item name -export const POST: RequestHandler = async ({ request, locals }) => { - const auth = await locals.auth(); - if (!auth?.user?.nickname) throw error(401, 'Not logged in'); +export const POST: RequestHandler = async ({ request, locals, url }) => { + const user = await getShoppingUser(locals, url); + if (!user) throw error(401, 'Not logged in'); const { name, category, icon } = await request.json(); if (!name || typeof name !== 'string') throw error(400, 'name is required'); diff --git a/src/routes/api/cospend/list/share/+server.ts b/src/routes/api/cospend/list/share/+server.ts new file mode 100644 index 0000000..30e2a44 --- /dev/null +++ b/src/routes/api/cospend/list/share/+server.ts @@ -0,0 +1,66 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createShareToken } from '$lib/server/shoppingAuth'; +import { ShoppingShareToken } from '$models/ShoppingShareToken'; +import { dbConnect } from '$utils/db'; + +// GET /api/cospend/list/share — list all active share tokens +export const GET: RequestHandler = async ({ locals }) => { + const auth = await locals.auth(); + if (!auth?.user?.nickname) throw error(401, 'Not logged in'); + + await dbConnect(); + const tokens = await ShoppingShareToken.find({ expiresAt: { $gt: new Date() } }) + .sort({ createdAt: -1 }) + .lean(); + + return json(tokens.map(t => ({ + id: t._id.toString(), + token: t.token, + expiresAt: t.expiresAt, + createdBy: t.createdBy, + createdAt: t.createdAt, + }))); +}; + +// POST /api/cospend/list/share — create a new share token +export const POST: RequestHandler = async ({ locals }) => { + const auth = await locals.auth(); + if (!auth?.user?.nickname) throw error(401, 'Not logged in'); + + const { token, expiresAt } = await createShareToken(auth.user.nickname); + return json({ token, expiresAt: expiresAt.toISOString() }); +}; + +// PATCH /api/cospend/list/share — update a token's expiry +export const PATCH: RequestHandler = async ({ request, locals }) => { + const auth = await locals.auth(); + if (!auth?.user?.nickname) throw error(401, 'Not logged in'); + + const { id, expiresAt } = await request.json(); + if (!id || !expiresAt) throw error(400, 'id and expiresAt required'); + + await dbConnect(); + const doc = await ShoppingShareToken.findByIdAndUpdate( + id, + { expiresAt: new Date(expiresAt) }, + { returnDocument: 'after', lean: true } + ); + if (!doc) throw error(404, 'Token not found'); + + return json({ ok: true }); +}; + +// DELETE /api/cospend/list/share — revoke a token +export const DELETE: RequestHandler = async ({ request, locals }) => { + const auth = await locals.auth(); + if (!auth?.user?.nickname) throw error(401, 'Not logged in'); + + const { id } = await request.json(); + if (!id) throw error(400, 'id required'); + + await dbConnect(); + await ShoppingShareToken.findByIdAndDelete(id); + + return json({ ok: true }); +}; diff --git a/src/routes/api/cospend/list/stream/+server.ts b/src/routes/api/cospend/list/stream/+server.ts index 57dd568..e854691 100644 --- a/src/routes/api/cospend/list/stream/+server.ts +++ b/src/routes/api/cospend/list/stream/+server.ts @@ -1,11 +1,12 @@ import type { RequestHandler } from './$types'; import { error } from '@sveltejs/kit'; import { addConnection, removeConnection } from '$lib/server/shoppingSSE'; +import { getShoppingUser } from '$lib/server/shoppingAuth'; // GET /api/cospend/list/stream — SSE endpoint for live shopping list updates -export const GET: RequestHandler = async ({ locals }) => { - const auth = await locals.auth(); - if (!auth?.user?.nickname) throw error(401, 'Not logged in'); +export const GET: RequestHandler = async ({ locals, url }) => { + const user = await getShoppingUser(locals, url); + if (!user) throw error(401, 'Not logged in'); const encoder = new TextEncoder(); let controllerRef: ReadableStreamDefaultController; diff --git a/src/routes/cospend/+layout.svelte b/src/routes/cospend/+layout.svelte index f7765a7..0084093 100644 --- a/src/routes/cospend/+layout.svelte +++ b/src/routes/cospend/+layout.svelte @@ -15,6 +15,7 @@ /** @type {string | null} */ let paymentId = $state(null); let user = $state(data.session?.user); + let isGuest = $derived(!data.session?.user); $effect(() => { // Check if URL contains payment view route OR if we have paymentId in state @@ -58,10 +59,14 @@
{#snippet links()} {/snippet} diff --git a/src/routes/cospend/list/+page.server.ts b/src/routes/cospend/list/+page.server.ts index 25139ea..c2f94a9 100644 --- a/src/routes/cospend/list/+page.server.ts +++ b/src/routes/cospend/list/+page.server.ts @@ -1,8 +1,17 @@ import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; +import { getShoppingUser } from '$lib/server/shoppingAuth'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, url }) => { const session = await locals.auth(); + const token = url.searchParams.get('token'); + + // Allow access with valid share token even without session + if (!session && token) { + const user = await getShoppingUser(locals, url); + if (user) return { session: null, shareToken: token }; + } + if (!session) throw redirect(302, '/login'); - return { session }; + return { session, shareToken: null }; }; diff --git a/src/routes/cospend/list/+page.svelte b/src/routes/cospend/list/+page.svelte index e6eafcb..09a5b96 100644 --- a/src/routes/cospend/list/+page.svelte +++ b/src/routes/cospend/list/+page.svelte @@ -9,8 +9,12 @@ import { SvelteSet } from 'svelte/reactivity'; import catalogData from '$lib/data/shoppingCatalog.json'; + import { Share2, X, Copy, Check } from '@lucide/svelte'; + let { data } = $props(); - let user = $derived(data.session?.user?.nickname || ''); + let user = $derived(data.session?.user?.nickname || 'guest'); + let shareToken = $derived(data.shareToken); + let isGuest = $derived(!data.session); const sync = getShoppingSync(); /** @type {Record} */ @@ -106,7 +110,7 @@ let checkedCount = $derived(sync.items.filter(i => i.checked).length); let totalCount = $derived(sync.items.length); - onMount(() => { sync.init(); }); + onMount(() => { sync.init(shareToken); }); onDestroy(() => { sync.disconnect(); }); async function addItem() { @@ -126,7 +130,7 @@ try { const cleanName = parseQuantity(name).name; console.log(`[shopping] Categorizing "${cleanName}" (item ${itemId})...`); - const res = await fetch('/api/cospend/list/categorize', { + const res = await fetch(sync.apiUrl('/api/cospend/list/categorize'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: cleanName }) @@ -192,12 +196,120 @@ editSaving = false; } + // --- Share links --- + let showShareModal = $state(false); + /** @type {{ id: string, token: string, expiresAt: string, createdBy: string, createdAt: string }[]} */ + let shareTokens = $state([]); + let shareLoading = $state(false); + /** @type {string | null} */ + let copiedId = $state(null); + let showCopyToast = $state(false); + + async function openShareModal() { + showShareModal = true; + await loadShareTokens(); + } + + async function loadShareTokens() { + shareLoading = true; + try { + const res = await fetch('/api/cospend/list/share'); + if (res.ok) shareTokens = await res.json(); + } catch (err) { + console.error('[shopping] Load tokens error:', err); + } finally { + shareLoading = false; + } + } + + /** @param {string} expiresAt */ + function formatTTL(expiresAt) { + const diff = new Date(expiresAt).getTime() - Date.now(); + if (diff <= 0) return 'abgelaufen'; + const mins = Math.round(diff / 60000); + if (mins < 60) return `${mins} Min.`; + const hours = Math.round(diff / 3600000); + if (hours < 24) return `${hours} Std.`; + const days = Math.round(diff / 86400000); + return `${days} Tag${days > 1 ? 'e' : ''}`; + } + + const TTL_OPTIONS = [ + { label: '1 Stunde', ms: 1 * 60 * 60 * 1000 }, + { label: '6 Stunden', ms: 6 * 60 * 60 * 1000 }, + { label: '24 Stunden', ms: 24 * 60 * 60 * 1000 }, + { label: '3 Tage', ms: 3 * 24 * 60 * 60 * 1000 }, + { label: '7 Tage', ms: 7 * 24 * 60 * 60 * 1000 }, + ]; + + /** + * @param {string} id + * @param {Event} e + */ + function onTTLChange(id, e) { + const ms = Number(/** @type {HTMLSelectElement} */ (e.currentTarget).value); + const newExpiry = new Date(Date.now() + ms).toISOString(); + updateTokenExpiry(id, newExpiry); + } + + async function createNewToken() { + try { + const res = await fetch('/api/cospend/list/share', { method: 'POST' }); + if (res.ok) await loadShareTokens(); + } catch (err) { + console.error('[shopping] Create token error:', err); + } + } + + /** @param {{ id: string, token: string }} t */ + async function copyTokenLink(t) { + const url = new URL('/cospend/list', window.location.origin); + url.searchParams.set('token', t.token); + await navigator.clipboard.writeText(url.toString()); + copiedId = t.id; + showCopyToast = true; + setTimeout(() => { copiedId = null; showCopyToast = false; }, 2000); + } + + /** @param {string} id */ + async function deleteToken(id) { + try { + await fetch('/api/cospend/list/share', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }); + shareTokens = shareTokens.filter(t => t.id !== id); + } catch (err) { + console.error('[shopping] Delete token error:', err); + } + } + + /** + * @param {string} id + * @param {string} newExpiry - ISO date string + */ + async function updateTokenExpiry(id, newExpiry) { + try { + await fetch('/api/cospend/list/share', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, expiresAt: newExpiry }) + }); + shareTokens = shareTokens.map(t => + t.id === id ? { ...t, expiresAt: newExpiry } : t + ); + } catch (err) { + console.error('[shopping] Update token error:', err); + } + } + async function saveEdit() { if (!editingItem) return; editSaving = true; const cleanName = parseQuantity(editingItem.name).name; try { - await fetch('/api/cospend/list/categorize/override', { + await fetch(sync.apiUrl('/api/cospend/list/categorize/override'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: cleanName, category: editCategory, icon: editIcon || null }) @@ -214,7 +326,14 @@
{/if} +{#if showShareModal} + + +
{ showShareModal = false; }}> + + + +
+{/if} + +{#if showCopyToast} +
+ Kopiert +
+{/if} +