From e18af0cc431964b151d812fa68ec0d53f573508c Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Fri, 20 Mar 2026 21:11:52 +0100 Subject: [PATCH] feat: add share API endpoints and in-memory rate limiting - POST/DELETE /api/notes/[id]/share for creating/revoking public links - GET /api/shared/[token] for fetching shared note data (no auth) - GET /api/shared/[token]/attachment/[attachmentId] for public images - Add /s/ and /api/shared to PUBLIC_PATHS in auth middleware - Add IP-based rate limiter (60 req/min) for public endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks.server.ts | 4 +- src/lib/server/ip-rate-limit.ts | 14 +++++++ src/routes/api/notes/[id]/share/+server.ts | 17 +++++++++ src/routes/api/shared/[token]/+server.ts | 20 ++++++++++ .../attachment/[attachmentId]/+server.ts | 37 +++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/ip-rate-limit.ts create mode 100644 src/routes/api/notes/[id]/share/+server.ts create mode 100644 src/routes/api/shared/[token]/+server.ts create mode 100644 src/routes/api/shared/[token]/attachment/[attachmentId]/+server.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index bcad068..85807c4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -8,7 +8,9 @@ const PUBLIC_PATHS = [ '/setup', '/api/auth/login', '/api/auth/setup', - '/api/auth/oauth' + '/api/auth/oauth', + '/s/', + '/api/shared' ]; export const handle: Handle = async ({ event, resolve }) => { diff --git a/src/lib/server/ip-rate-limit.ts b/src/lib/server/ip-rate-limit.ts new file mode 100644 index 0000000..98c1607 --- /dev/null +++ b/src/lib/server/ip-rate-limit.ts @@ -0,0 +1,14 @@ +const store = new Map(); +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 60; + +export function checkIpRateLimit(ip: string): boolean { + const now = Date.now(); + const entry = store.get(ip); + if (!entry || now - entry.windowStart > WINDOW_MS) { + store.set(ip, { count: 1, windowStart: now }); + return true; + } + entry.count++; + return entry.count <= MAX_REQUESTS; +} diff --git a/src/routes/api/notes/[id]/share/+server.ts b/src/routes/api/notes/[id]/share/+server.ts new file mode 100644 index 0000000..11142e1 --- /dev/null +++ b/src/routes/api/notes/[id]/share/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { getUserId } from '$lib/server/api-utils.js'; +import { createShare, revokeShare } from '$lib/server/shares-service.js'; +import { db } from '$lib/server/db/index.js'; + +export const POST: RequestHandler = async ({ params, url, ...event }) => { + const userId = getUserId(event); + const { token } = createShare(db, params.id, userId); + return json({ token, url: `${url.origin}/s/${token}` }, { status: 201 }); +}; + +export const DELETE: RequestHandler = async ({ params, ...event }) => { + const userId = getUserId(event); + revokeShare(db, params.id, userId); + return new Response(null, { status: 204 }); +}; diff --git a/src/routes/api/shared/[token]/+server.ts b/src/routes/api/shared/[token]/+server.ts new file mode 100644 index 0000000..6d687f7 --- /dev/null +++ b/src/routes/api/shared/[token]/+server.ts @@ -0,0 +1,20 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { getSharedNote } from '$lib/server/shares-service.js'; +import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js'; +import { db } from '$lib/server/db/index.js'; + +export const GET: RequestHandler = async ({ params, getClientAddress }) => { + const ip = getClientAddress(); + if (!checkIpRateLimit(ip)) { + throw error(429, 'Too many requests'); + } + + const data = getSharedNote(db, params.token); + if (!data) { + throw error(404, 'Not found'); + } + + const { noteId: _, ...publicData } = data; + return json(publicData); +}; diff --git a/src/routes/api/shared/[token]/attachment/[attachmentId]/+server.ts b/src/routes/api/shared/[token]/attachment/[attachmentId]/+server.ts new file mode 100644 index 0000000..046a2e8 --- /dev/null +++ b/src/routes/api/shared/[token]/attachment/[attachmentId]/+server.ts @@ -0,0 +1,37 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { getSharedNote } from '$lib/server/shares-service.js'; +import { getAttachment } from '$lib/server/attachments.js'; +import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js'; +import { db } from '$lib/server/db/index.js'; +import { readFile } from 'fs/promises'; + +export const GET: RequestHandler = async ({ params, url, getClientAddress }) => { + const ip = getClientAddress(); + if (!checkIpRateLimit(ip)) { + throw error(429, 'Too many requests'); + } + + const data = getSharedNote(db, params.token); + if (!data) { + throw error(404, 'Not found'); + } + + const attachment = await getAttachment(db, params.attachmentId, data.noteId); + if (!attachment) { + throw error(404, 'Attachment not found'); + } + + const wantThumb = url.searchParams.get('thumb') === '1'; + const filePath = wantThumb && attachment.thumbnailPath ? attachment.thumbnailPath : attachment.path; + const mimeType = wantThumb && attachment.thumbnailPath ? 'image/webp' : attachment.mimeType; + + const buffer = await readFile(filePath); + return new Response(buffer, { + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `inline; filename="${attachment.filename}"`, + 'Cache-Control': 'public, max-age=31536000, immutable' + } + }); +};