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) <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-20 22:06:41 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 90996cc72d
commit 4ffa34afa0
5 changed files with 91 additions and 1 deletions
+3 -1
View File
@@ -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 }) => {
+14
View File
@@ -0,0 +1,14 @@
const store = new Map<string, { count: number; windowStart: number }>();
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;
}
@@ -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 });
};
+20
View File
@@ -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);
};
@@ -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'
}
});
};