assafvayner HF Staff commited on
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
- if (v.description.length === 0 || v.description.length > 120) errors.description = 'Description is required (max 120 characters)';
 
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: v.description, amountCents, currency: opts.currency, date: v.date, paidBy: v.paidBy, split, shares };
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
- return { from: q.get('from') ?? viewerId, to: q.get('to') ?? '', amount: q.get('amount') ?? '', date: today, note: '' };
 
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] ?? 'application/octet-stream';
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
  });