From 21022eaa7340559088f3def144d7a2fe770257eb Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:13:45 +0200 Subject: [PATCH] feat(server): tus hook helpers (metadata parse, extension extract, ownership check) --- packages/server/src/utils/tusHooks.test.ts | 62 ++++++++++++++++++++++ packages/server/src/utils/tusHooks.ts | 57 ++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 packages/server/src/utils/tusHooks.test.ts create mode 100644 packages/server/src/utils/tusHooks.ts diff --git a/packages/server/src/utils/tusHooks.test.ts b/packages/server/src/utils/tusHooks.test.ts new file mode 100644 index 00000000..180215f7 --- /dev/null +++ b/packages/server/src/utils/tusHooks.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { + parseUploadMetadata, + extractExtension, + buildTusMetadata, + isOwnerOfUpload, +} from './tusHooks.js'; + +describe('parseUploadMetadata', () => { + it('parses base64-encoded metadata pairs', () => { + const filename = Buffer.from('photo.png').toString('base64'); + const meta = parseUploadMetadata(`filename ${filename},foo bar`); + expect(meta.filename).toBe('photo.png'); + expect(meta.foo).toBeUndefined(); // 'bar' is not valid base64 padding here + }); + + it('returns empty object on null input', () => { + expect(parseUploadMetadata(null)).toEqual({}); + }); +}); + +describe('extractExtension', () => { + it('lowercases the extension', () => { + expect(extractExtension('Photo.PNG')).toBe('.png'); + }); + it('returns empty string when no extension', () => { + expect(extractExtension('Makefile')).toBe(''); + }); + it('strips path components', () => { + expect(extractExtension('../../../etc/passwd.txt')).toBe('.txt'); + }); +}); + +describe('buildTusMetadata', () => { + it('includes snowflakeId, userId, originalName', () => { + const meta = buildTusMetadata({ + snowflakeId: '1234', + userId: 'u-9', + originalName: 'photo.png', + }); + expect(meta).toMatchObject({ + snowflakeId: '1234', + userId: 'u-9', + originalName: 'photo.png', + }); + }); +}); + +describe('isOwnerOfUpload', () => { + it('returns true on matching userId', () => { + expect(isOwnerOfUpload({ userId: 'u-9' }, 'u-9')).toBe(true); + }); + it('returns false on mismatch', () => { + expect(isOwnerOfUpload({ userId: 'u-9' }, 'u-10')).toBe(false); + }); + it('returns false on missing metadata', () => { + expect(isOwnerOfUpload({}, 'u-9')).toBe(false); + }); + it('returns false when metadata.userId is an empty string', () => { + expect(isOwnerOfUpload({ userId: '' }, 'u-9')).toBe(false); + }); +}); diff --git a/packages/server/src/utils/tusHooks.ts b/packages/server/src/utils/tusHooks.ts new file mode 100644 index 00000000..d54a1065 --- /dev/null +++ b/packages/server/src/utils/tusHooks.ts @@ -0,0 +1,57 @@ +import * as path from 'node:path'; + +export interface UploadMetadata { + snowflakeId?: string; + userId?: string; + originalName?: string; + [k: string]: string | undefined; +} + +/** Parse the comma-separated `Upload-Metadata` header into a flat object. */ +export function parseUploadMetadata(raw: string | null | undefined): UploadMetadata { + if (!raw) return {}; + const out: UploadMetadata = {}; + for (const pair of raw.split(',')) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const [key, b64] = trimmed.split(' '); + if (!key) continue; + if (!b64) { + // Boolean-style key (no value) + continue; + } + try { + const decoded = Buffer.from(b64, 'base64').toString('utf8'); + // Reject if base64 round-trip mismatches — protects against odd encodings + if (Buffer.from(decoded, 'utf8').toString('base64').replace(/=+$/, '') !== + b64.replace(/=+$/, '')) continue; + out[key] = decoded; + } catch { /* ignore */ } + } + return out; +} + +/** Extract a sanitized lowercase extension, stripped of path components. */ +export function extractExtension(originalName: string): string { + const base = path.basename(originalName); + const ext = path.extname(base).toLowerCase(); + return ext; +} + +/** Build the metadata object stored on tus uploads at PRE_CREATE. */ +export function buildTusMetadata(input: { + snowflakeId: string; + userId: string; + originalName: string; +}): UploadMetadata { + return { + snowflakeId: input.snowflakeId, + userId: input.userId, + originalName: input.originalName, + }; +} + +/** True iff the supplied userId matches metadata.userId. Used in PRE_PATCH. */ +export function isOwnerOfUpload(metadata: UploadMetadata, userId: string): boolean { + return Boolean(metadata.userId) && metadata.userId === userId; +}