Spaces:
Running
Running
Commit ·
fa363c5
1
Parent(s): 1c7b998
fix(photos,forms): own-property content types, dimension bounds, VP8L signature, trimmed inputs
Browse files
src/lib/server/forms/expense-form.ts
CHANGED
|
@@ -57,7 +57,8 @@ export function buildExpenseBody(v: ExpenseFormValues, opts: BuildOptions): Buil
|
|
| 57 |
const errors: FieldErrors = {};
|
| 58 |
const active = new Set(opts.activeMemberIds);
|
| 59 |
|
| 60 |
-
|
|
|
|
| 61 |
const amountCents = parseAmountToCents(v.amount);
|
| 62 |
if (amountCents === null) errors.amount = 'Enter a positive amount with at most 2 decimals';
|
| 63 |
if (!isValidDate(v.date)) errors.date = 'Enter a valid date';
|
|
@@ -106,7 +107,7 @@ export function buildExpenseBody(v: ExpenseFormValues, opts: BuildOptions): Buil
|
|
| 106 |
}
|
| 107 |
|
| 108 |
if (Object.keys(errors).length > 0 || !split || amountCents === null) return { ok: false, errors };
|
| 109 |
-
const body: ExpenseBody = { description
|
| 110 |
const trimmedNotes = v.notes.trim();
|
| 111 |
if (trimmedNotes.length > 0) body.notes = trimmedNotes;
|
| 112 |
return { ok: true, body };
|
|
|
|
| 57 |
const errors: FieldErrors = {};
|
| 58 |
const active = new Set(opts.activeMemberIds);
|
| 59 |
|
| 60 |
+
const description = v.description.trim();
|
| 61 |
+
if (description.length === 0 || description.length > 120) errors.description = 'Description is required (max 120 characters)';
|
| 62 |
const amountCents = parseAmountToCents(v.amount);
|
| 63 |
if (amountCents === null) errors.amount = 'Enter a positive amount with at most 2 decimals';
|
| 64 |
if (!isValidDate(v.date)) errors.date = 'Enter a valid date';
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
if (Object.keys(errors).length > 0 || !split || amountCents === null) return { ok: false, errors };
|
| 110 |
+
const body: ExpenseBody = { description, amountCents, currency: opts.currency, date: v.date, paidBy: v.paidBy, split, shares };
|
| 111 |
const trimmedNotes = v.notes.trim();
|
| 112 |
if (trimmedNotes.length > 0) body.notes = trimmedNotes;
|
| 113 |
return { ok: true, body };
|
src/lib/server/forms/settlement-form.ts
CHANGED
|
@@ -16,7 +16,8 @@ export function settlementToFormValues(s: Settlement): SettlementFormValues {
|
|
| 16 |
|
| 17 |
/** Prefill from `/balances` "Settle up" links: ?from=&to=&amount=. */
|
| 18 |
export function settlementFormFromQuery(q: URLSearchParams, viewerId: string, today: string): SettlementFormValues {
|
| 19 |
-
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
export function buildSettlementBody(v: SettlementFormValues, activeMemberIds: string[]): BuildResult<SettlementBody, SettlementField> {
|
|
|
|
| 16 |
|
| 17 |
/** Prefill from `/balances` "Settle up" links: ?from=&to=&amount=. */
|
| 18 |
export function settlementFormFromQuery(q: URLSearchParams, viewerId: string, today: string): SettlementFormValues {
|
| 19 |
+
const clip = (v: string): string => v.trim().slice(0, 64);
|
| 20 |
+
return { from: clip(q.get('from') ?? viewerId), to: clip(q.get('to') ?? ''), amount: clip(q.get('amount') ?? ''), date: today, note: '' };
|
| 21 |
}
|
| 22 |
|
| 23 |
export function buildSettlementBody(v: SettlementFormValues, activeMemberIds: string[]): BuildResult<SettlementBody, SettlementField> {
|
src/lib/server/photos.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function photoKey(expenseId: string, photoId: string, type: ImageType): s
|
|
| 12 |
|
| 13 |
export function contentTypeForKey(key: string): string {
|
| 14 |
const ext = key.slice(key.lastIndexOf('.') + 1);
|
| 15 |
-
return TYPE_BY_EXT[ext]
|
| 16 |
}
|
| 17 |
|
| 18 |
export function sniffImageType(b: Uint8Array): ImageType | null {
|
|
@@ -22,6 +22,8 @@ export function sniffImageType(b: Uint8Array): ImageType | null {
|
|
| 22 |
return null;
|
| 23 |
}
|
| 24 |
|
|
|
|
|
|
|
| 25 |
export function imageDimensions(b: Uint8Array, type: ImageType): { width: number; height: number } | null {
|
| 26 |
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
| 27 |
try {
|
|
@@ -56,6 +58,7 @@ export function imageDimensions(b: Uint8Array, type: ImageType): { width: number
|
|
| 56 |
if (chunk === 'VP8X' && b.length >= 30) return check(1 + u24le(b, 24), 1 + u24le(b, 27));
|
| 57 |
if (chunk === 'VP8 ' && b.length >= 30) return check(dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff);
|
| 58 |
if (chunk === 'VP8L' && b.length >= 25) {
|
|
|
|
| 59 |
const w = 1 + (((b[22] & 0x3f) << 8) | b[21]);
|
| 60 |
const h = 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6));
|
| 61 |
return check(w, h);
|
|
@@ -73,5 +76,5 @@ function u24le(b: Uint8Array, i: number): number {
|
|
| 73 |
return b[i] | (b[i + 1] << 8) | (b[i + 2] << 16);
|
| 74 |
}
|
| 75 |
function check(width: number, height: number): { width: number; height: number } | null {
|
| 76 |
-
return width > 0 && height > 0 ? { width, height } : null;
|
| 77 |
}
|
|
|
|
| 12 |
|
| 13 |
export function contentTypeForKey(key: string): string {
|
| 14 |
const ext = key.slice(key.lastIndexOf('.') + 1);
|
| 15 |
+
return Object.hasOwn(TYPE_BY_EXT, ext) ? TYPE_BY_EXT[ext] : 'application/octet-stream';
|
| 16 |
}
|
| 17 |
|
| 18 |
export function sniffImageType(b: Uint8Array): ImageType | null {
|
|
|
|
| 22 |
return null;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
export const MAX_IMAGE_DIMENSION = 20_000;
|
| 26 |
+
|
| 27 |
export function imageDimensions(b: Uint8Array, type: ImageType): { width: number; height: number } | null {
|
| 28 |
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
| 29 |
try {
|
|
|
|
| 58 |
if (chunk === 'VP8X' && b.length >= 30) return check(1 + u24le(b, 24), 1 + u24le(b, 27));
|
| 59 |
if (chunk === 'VP8 ' && b.length >= 30) return check(dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff);
|
| 60 |
if (chunk === 'VP8L' && b.length >= 25) {
|
| 61 |
+
if (b[20] !== 0x2f) return null;
|
| 62 |
const w = 1 + (((b[22] & 0x3f) << 8) | b[21]);
|
| 63 |
const h = 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6));
|
| 64 |
return check(w, h);
|
|
|
|
| 76 |
return b[i] | (b[i + 1] << 8) | (b[i + 2] << 16);
|
| 77 |
}
|
| 78 |
function check(width: number, height: number): { width: number; height: number } | null {
|
| 79 |
+
return width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION ? { width, height } : null;
|
| 80 |
}
|
tests/unit/forms/expense-form.test.ts
CHANGED
|
@@ -54,6 +54,11 @@ describe('buildExpenseBody', () => {
|
|
| 54 |
expect(r2.ok && r2.body.notes).toBe('hi');
|
| 55 |
});
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
it('reports field errors', () => {
|
| 58 |
const errs = (v: Partial<typeof base>) => {
|
| 59 |
const r = buildExpenseBody({ ...base, ...v }, opts);
|
|
|
|
| 54 |
expect(r2.ok && r2.body.notes).toBe('hi');
|
| 55 |
});
|
| 56 |
|
| 57 |
+
it('trims the description before storing it', () => {
|
| 58 |
+
const r = buildExpenseBody({ ...base, description: ' Pizza ' }, opts);
|
| 59 |
+
expect(r.ok && r.body.description).toBe('Pizza');
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
it('reports field errors', () => {
|
| 63 |
const errs = (v: Partial<typeof base>) => {
|
| 64 |
const r = buildExpenseBody({ ...base, ...v }, opts);
|
tests/unit/forms/settlement-form.test.ts
CHANGED
|
@@ -30,4 +30,13 @@ describe('settlement form', () => {
|
|
| 30 |
expect(settlementFormFromQuery(q, 'u_b', '2026-08-29')).toEqual({ from: 'u_b', to: 'u_a', amount: '90.00', date: '2026-08-29', note: '' });
|
| 31 |
expect(settlementFormFromQuery(new URLSearchParams(), 'u_b', '2026-08-29')).toEqual({ from: 'u_b', to: '', amount: '', date: '2026-08-29', note: '' });
|
| 32 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
});
|
|
|
|
| 30 |
expect(settlementFormFromQuery(q, 'u_b', '2026-08-29')).toEqual({ from: 'u_b', to: 'u_a', amount: '90.00', date: '2026-08-29', note: '' });
|
| 31 |
expect(settlementFormFromQuery(new URLSearchParams(), 'u_b', '2026-08-29')).toEqual({ from: 'u_b', to: '', amount: '', date: '2026-08-29', note: '' });
|
| 32 |
});
|
| 33 |
+
|
| 34 |
+
it('trims and truncates query-derived values', () => {
|
| 35 |
+
const q = new URLSearchParams({ from: ' u_b ', to: ' u_a ', amount: ` ${'9'.repeat(200)} ` });
|
| 36 |
+
const values = settlementFormFromQuery(q, 'u_b', '2026-08-29');
|
| 37 |
+
expect(values.from).toBe('u_b');
|
| 38 |
+
expect(values.to).toBe('u_a');
|
| 39 |
+
expect(values.amount).toBe('9'.repeat(64));
|
| 40 |
+
expect(values.amount.length).toBe(64);
|
| 41 |
+
});
|
| 42 |
});
|
tests/unit/photos.test.ts
CHANGED
|
@@ -23,6 +23,24 @@ const webpX = (w: number, h: number) => {
|
|
| 23 |
b.set([hh & 0xff, (hh >> 8) & 0xff, (hh >> 16) & 0xff], 27);
|
| 24 |
return b;
|
| 25 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
describe('photos', () => {
|
| 28 |
it('sniffs types by magic bytes', () => {
|
|
@@ -40,6 +58,26 @@ describe('photos', () => {
|
|
| 40 |
expect(imageDimensions(new Uint8Array([0xff, 0xd8, 0xff]), 'image/jpeg')).toBeNull();
|
| 41 |
});
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
it('builds and recognises keys', () => {
|
| 44 |
const key = photoKey('e_01HZXXXXXXXXXXXXXXXXXXXXXX', '01HZYYYYYYYYYYYYYYYYYYYYYY', 'image/jpeg');
|
| 45 |
expect(key).toBe('photos/e_01HZXXXXXXXXXXXXXXXXXXXXXX/01HZYYYYYYYYYYYYYYYYYYYYYY.jpg');
|
|
@@ -47,5 +85,6 @@ describe('photos', () => {
|
|
| 47 |
expect(PHOTO_KEY_RE.test('photos/../users.json')).toBe(false);
|
| 48 |
expect(contentTypeForKey(key)).toBe('image/jpeg');
|
| 49 |
expect(contentTypeForKey('photos/e_X/y.webp')).toBe('image/webp');
|
|
|
|
| 50 |
});
|
| 51 |
});
|
|
|
|
| 23 |
b.set([hh & 0xff, (hh >> 8) & 0xff, (hh >> 16) & 0xff], 27);
|
| 24 |
return b;
|
| 25 |
};
|
| 26 |
+
const vp8L = (w: number, h: number, sig = 0x2f) => {
|
| 27 |
+
const b = new Uint8Array(25);
|
| 28 |
+
b.set([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c], 0);
|
| 29 |
+
b[20] = sig;
|
| 30 |
+
const ww = w - 1, hh = h - 1;
|
| 31 |
+
b[21] = ww & 0xff;
|
| 32 |
+
b[22] = ((ww >> 8) & 0x3f) | ((hh & 0x3) << 6);
|
| 33 |
+
b[23] = (hh >> 2) & 0xff;
|
| 34 |
+
b[24] = (hh >> 10) & 0x0f;
|
| 35 |
+
return b;
|
| 36 |
+
};
|
| 37 |
+
const vp8Lossy = (w: number, h: number) => {
|
| 38 |
+
const b = new Uint8Array(30);
|
| 39 |
+
b.set([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20], 0);
|
| 40 |
+
new DataView(b.buffer).setUint16(26, w, true);
|
| 41 |
+
new DataView(b.buffer).setUint16(28, h, true);
|
| 42 |
+
return b;
|
| 43 |
+
};
|
| 44 |
|
| 45 |
describe('photos', () => {
|
| 46 |
it('sniffs types by magic bytes', () => {
|
|
|
|
| 58 |
expect(imageDimensions(new Uint8Array([0xff, 0xd8, 0xff]), 'image/jpeg')).toBeNull();
|
| 59 |
});
|
| 60 |
|
| 61 |
+
it('rejects absurd or oversized dimensions', () => {
|
| 62 |
+
expect(imageDimensions(png(4294967295, 4294967295), 'image/png')).toBeNull();
|
| 63 |
+
expect(imageDimensions(png(20000, 1), 'image/png')).toEqual({ width: 20000, height: 1 });
|
| 64 |
+
expect(imageDimensions(png(20001, 1), 'image/png')).toBeNull();
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it('parses VP8L only when the signature byte is correct', () => {
|
| 68 |
+
expect(imageDimensions(vp8L(3, 2), 'image/webp')).toEqual({ width: 3, height: 2 });
|
| 69 |
+
expect(imageDimensions(vp8L(3, 2, 0x00), 'image/webp')).toBeNull();
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it('parses VP8 (lossy) dimensions', () => {
|
| 73 |
+
expect(imageDimensions(vp8Lossy(100, 50), 'image/webp')).toEqual({ width: 100, height: 50 });
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
it('returns null promptly for a zero-length JPEG segment with no trailing data', () => {
|
| 77 |
+
const b = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00]);
|
| 78 |
+
expect(imageDimensions(b, 'image/jpeg')).toBeNull();
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
it('builds and recognises keys', () => {
|
| 82 |
const key = photoKey('e_01HZXXXXXXXXXXXXXXXXXXXXXX', '01HZYYYYYYYYYYYYYYYYYYYYYY', 'image/jpeg');
|
| 83 |
expect(key).toBe('photos/e_01HZXXXXXXXXXXXXXXXXXXXXXX/01HZYYYYYYYYYYYYYYYYYYYYYY.jpg');
|
|
|
|
| 85 |
expect(PHOTO_KEY_RE.test('photos/../users.json')).toBe(false);
|
| 86 |
expect(contentTypeForKey(key)).toBe('image/jpeg');
|
| 87 |
expect(contentTypeForKey('photos/e_X/y.webp')).toBe('image/webp');
|
| 88 |
+
expect(contentTypeForKey('photos/e_X/y.constructor')).toBe('application/octet-stream');
|
| 89 |
});
|
| 90 |
});
|