fix(transcription): replace sendBeacon with fetch keepalive; add catch-all API proxy #304
@@ -73,7 +73,7 @@ $effect(() => {
|
||||
|
||||
$effect(() => {
|
||||
function onBeforeUnload() {
|
||||
autoSave.flushViaBeacon();
|
||||
autoSave.flushOnUnload();
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
|
||||
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
|
||||
|
||||
@@ -82,3 +82,82 @@ describe('createBlockAutoSave', () => {
|
||||
expect(mockSaveFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushOnUnload', () => {
|
||||
let mockFetch: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockSaveFn.mockClear();
|
||||
mockSaveFn.mockResolvedValue(undefined);
|
||||
mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends a PUT request with keepalive:true for each pending block', () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'hello');
|
||||
as.handleTextChange('block-2', 'world');
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/documents/doc-1/transcription-blocks/block-1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
keepalive: true,
|
||||
body: JSON.stringify({ text: 'hello' })
|
||||
})
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/documents/doc-1/transcription-blocks/block-2',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
keepalive: true,
|
||||
body: JSON.stringify({ text: 'world' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call navigator.sendBeacon', () => {
|
||||
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text');
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(sendBeaconSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when there are no pending edits', () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels the debounce timer so saveFn is not also called', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text');
|
||||
as.flushOnUnload();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(mockSaveFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text');
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
// debounce has fired; pendingTexts should be empty now
|
||||
mockFetch.mockClear();
|
||||
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,12 +94,15 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
saveStates.delete(blockId);
|
||||
}
|
||||
|
||||
function flushViaBeacon(): void {
|
||||
function flushOnUnload(): void {
|
||||
for (const [blockId, text] of pendingTexts) {
|
||||
clearDebounce(blockId);
|
||||
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
|
||||
const body = JSON.stringify({ text });
|
||||
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
|
||||
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
keepalive: true
|
||||
});
|
||||
pendingTexts.delete(blockId);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +124,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
handleBlur,
|
||||
handleRetry,
|
||||
clearBlock,
|
||||
flushViaBeacon,
|
||||
flushOnUnload,
|
||||
destroy
|
||||
};
|
||||
}
|
||||
|
||||
87
frontend/src/routes/api/[...path]/+server.ts
Normal file
87
frontend/src/routes/api/[...path]/+server.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from 'process';
|
||||
|
||||
const NO_BODY_METHODS = new Set(['GET', 'HEAD', 'DELETE']);
|
||||
|
||||
// Hop-by-hop headers must not be forwarded to the client — they are connection-scoped.
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
'transfer-encoding',
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'upgrade',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'proxy-connection'
|
||||
]);
|
||||
|
||||
async function proxy(event: Parameters<RequestHandler>[0]): Promise<Response> {
|
||||
const rawPath = event.params.path;
|
||||
|
||||
// Block path traversal attempts — SvelteKit decodes %2F before routing,
|
||||
// so encoded dots could produce ../../../ sequences after normalization.
|
||||
if (rawPath.includes('..') || /%(2e)/i.test(rawPath)) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// Block Actuator endpoints — heapdump, env, configprops expose credentials
|
||||
// and heap memory and must never be reachable through the SvelteKit proxy.
|
||||
if (rawPath.startsWith('actuator')) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const backendUrl = `${apiUrl}/api/${rawPath}${event.url.search}`;
|
||||
|
||||
const hasBody = !NO_BODY_METHODS.has(event.request.method);
|
||||
|
||||
// Early exit: reject well-behaved clients that advertise an oversized body
|
||||
// before we buffer anything.
|
||||
const contentLength = event.request.headers.get('Content-Length');
|
||||
if (contentLength && parseInt(contentLength, 10) > 1_048_576) {
|
||||
return new Response('Payload Too Large', { status: 413 });
|
||||
}
|
||||
|
||||
// Buffer the body so we can check its actual size. This also catches chunked
|
||||
// requests that omit Content-Length entirely (parseInt(null) → NaN → passes
|
||||
// the header check above).
|
||||
const bodyBuffer = hasBody ? await event.request.arrayBuffer() : undefined;
|
||||
if (bodyBuffer && bodyBuffer.byteLength > 1_048_576) {
|
||||
return new Response('Payload Too Large', { status: 413 });
|
||||
}
|
||||
|
||||
// Only forward Content-Type from the browser request — other headers (Cookie,
|
||||
// Authorization, Accept-Encoding, etc.) must not be forwarded directly.
|
||||
// Authentication is injected by handleFetch in hooks.server.ts from the
|
||||
// server-side auth_token cookie.
|
||||
const contentType = event.request.headers.get('Content-Type');
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
if (contentType) requestHeaders['Content-Type'] = contentType;
|
||||
|
||||
const response = await event.fetch(backendUrl, {
|
||||
method: event.request.method,
|
||||
headers: requestHeaders,
|
||||
body: bodyBuffer
|
||||
});
|
||||
|
||||
// Forward all response headers except hop-by-hop so that Content-Disposition,
|
||||
// ETag, Cache-Control, Location (redirects), etc. reach the browser.
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
|
||||
responseHeaders[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = (event) => proxy(event);
|
||||
export const POST: RequestHandler = (event) => proxy(event);
|
||||
export const PUT: RequestHandler = (event) => proxy(event);
|
||||
export const PATCH: RequestHandler = (event) => proxy(event);
|
||||
export const DELETE: RequestHandler = (event) => proxy(event);
|
||||
221
frontend/src/routes/api/[...path]/proxy.spec.ts
Normal file
221
frontend/src/routes/api/[...path]/proxy.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
process.env.API_INTERNAL_URL = 'http://backend:8080';
|
||||
|
||||
const { GET, PUT, POST, PATCH } = await import('./+server');
|
||||
|
||||
function makeEvent(
|
||||
path: string,
|
||||
method = 'GET',
|
||||
mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
)
|
||||
) {
|
||||
return {
|
||||
params: { path },
|
||||
request: new Request(`http://localhost/api/${path}`, { method }),
|
||||
url: new URL(`http://localhost/api/${path}`),
|
||||
fetch: mockFetch
|
||||
};
|
||||
}
|
||||
|
||||
describe('catch-all API proxy — security guards', () => {
|
||||
it('returns 400 for path traversal with double-dot', async () => {
|
||||
const event = makeEvent('documents/../../../etc/passwd');
|
||||
const response = await GET(event as never);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for URL-encoded dot traversal (%2e)', async () => {
|
||||
const event = makeEvent('documents/%2e%2e/sensitive');
|
||||
const response = await GET(event as never);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for mixed-case encoded traversal (%2E)', async () => {
|
||||
const event = makeEvent('documents/%2E%2E/sensitive');
|
||||
const response = await GET(event as never);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for actuator paths', async () => {
|
||||
const event = makeEvent('actuator/heapdump');
|
||||
const response = await GET(event as never);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 for actuator/env', async () => {
|
||||
const event = makeEvent('actuator/env');
|
||||
const response = await GET(event as never);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catch-all API proxy — body size limit', () => {
|
||||
it('returns 413 when Content-Length exceeds 1 MB', async () => {
|
||||
const mockFetch = vi.fn();
|
||||
const event = makeEvent('documents', 'POST', mockFetch);
|
||||
(event.request as Request) = new Request('http://localhost/api/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': '1048577' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
const response = await POST(event as never);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 413 when actual body exceeds 1 MB even if Content-Length header is absent or lying', async () => {
|
||||
const mockFetch = vi.fn();
|
||||
const event = makeEvent('documents', 'POST', mockFetch);
|
||||
(event.request as Request) = new Request('http://localhost/api/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': '0' },
|
||||
body: new Uint8Array(1_048_577) // 1 MB + 1 byte, but Content-Length says 0
|
||||
});
|
||||
|
||||
const response = await POST(event as never);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards request when Content-Length header is absent', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const event = makeEvent('documents', 'POST', mockFetch);
|
||||
(event.request as Request) = new Request('http://localhost/api/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'hello' })
|
||||
});
|
||||
|
||||
const response = await POST(event as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reject request with Content-Length exactly at 1 MB', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const event = makeEvent('documents', 'POST', mockFetch);
|
||||
(event.request as Request) = new Request('http://localhost/api/documents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': '1048576' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
const response = await POST(event as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catch-all API proxy — forwarding', () => {
|
||||
it('forwards PUT request to backend with correct URL', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response('{"id":"block-1"}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
const event = makeEvent('documents/doc-1/transcription-blocks/block-1', 'PUT', mockFetch);
|
||||
(event.request as Request) = new Request(
|
||||
'http://localhost/api/documents/doc-1/transcription-blocks/block-1',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'hello' })
|
||||
}
|
||||
);
|
||||
|
||||
const response = await PUT(event as never);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://backend:8080/api/documents/doc-1/transcription-blocks/block-1',
|
||||
expect.objectContaining({ method: 'PUT' })
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('passes through the backend response status code', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 404 }));
|
||||
const event = makeEvent('documents/nonexistent', 'GET', mockFetch);
|
||||
|
||||
const response = await GET(event as never);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('forwards Content-Disposition response header', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response('data', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'attachment; filename="document.pdf"'
|
||||
}
|
||||
})
|
||||
);
|
||||
const event = makeEvent('documents/doc-1/export', 'GET', mockFetch);
|
||||
|
||||
const response = await GET(event as never);
|
||||
|
||||
expect(response.headers.get('Content-Disposition')).toBe('attachment; filename="document.pdf"');
|
||||
});
|
||||
|
||||
it('does not forward proxy-connection hop-by-hop header', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response('data', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Proxy-Connection': 'keep-alive'
|
||||
}
|
||||
})
|
||||
);
|
||||
const event = makeEvent('documents', 'GET', mockFetch);
|
||||
|
||||
const response = await GET(event as never);
|
||||
|
||||
expect(response.headers.get('Proxy-Connection')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not forward hop-by-hop headers like transfer-encoding', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
new Response('data', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Transfer-Encoding': 'chunked'
|
||||
}
|
||||
})
|
||||
);
|
||||
const event = makeEvent('documents', 'GET', mockFetch);
|
||||
|
||||
const response = await GET(event as never);
|
||||
|
||||
expect(response.headers.get('Transfer-Encoding')).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards PATCH request to backend with correct URL', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const event = makeEvent('documents/doc-1', 'PATCH', mockFetch);
|
||||
(event.request as Request) = new Request('http://localhost/api/documents/doc-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'updated' })
|
||||
});
|
||||
|
||||
const response = await PATCH(event as never);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://backend:8080/api/documents/doc-1',
|
||||
expect.objectContaining({ method: 'PATCH' })
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user