From 43f3de2f9043cf88f75ac81c6a21994251098243 Mon Sep 17 00:00:00 2001 From: walker Date: Mon, 24 Aug 2026 18:27:21 +0800 Subject: [PATCH] fix(cli): validate scheduled post timestamps strictly --- packages/cli/src/commands/promote.test.ts | 10 ++++++++- packages/cli/src/commands/promote.ts | 25 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/promote.test.ts b/packages/cli/src/commands/promote.test.ts index 8ed4ec90..f7cd6605 100644 --- a/packages/cli/src/commands/promote.test.ts +++ b/packages/cli/src/commands/promote.test.ts @@ -14,9 +14,17 @@ import { describe('promote schedule option parser', () => { it('parses a valid ISO timestamp', () => { expect(parseSchedule('2026-08-01T18:30:00Z').toISOString()).toBe('2026-08-01T18:30:00.000Z'); + expect(parseSchedule('2026-08-01T18:30:00+08:00').toISOString()).toBe('2026-08-01T10:30:00.000Z'); }); - it.each(['not-a-date', '2026-99-99T18:30:00Z', ''])('rejects invalid timestamp %j', (value) => { + it.each([ + 'not-a-date', + '2026-99-99T18:30:00Z', + '2026-02-30T18:30:00Z', + '2026-08-01T18:30:00', + '2026-08-01T25:00:00Z', + '', + ])('rejects invalid or timezone-ambiguous timestamp %j', (value) => { expect(() => parseSchedule(value)).toThrow('valid ISO timestamp'); }); }); diff --git a/packages/cli/src/commands/promote.ts b/packages/cli/src/commands/promote.ts index bde655f2..8a73b38a 100644 --- a/packages/cli/src/commands/promote.ts +++ b/packages/cli/src/commands/promote.ts @@ -829,9 +829,32 @@ function inferMediaKind(file: string): 'image' | 'video' | 'gif' { } export function parseSchedule(value: string): Date { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); + if (!match) { + throw new InvalidArgumentError('must be a valid ISO timestamp with a timezone'); + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const timezone = match[7]!; + const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const timezoneParts = timezone === 'Z' ? undefined : /^([+-])(\d{2}):(\d{2})$/.exec(timezone); + + if ( + month < 1 || month > 12 || day < 1 || day > daysInMonth + || hour > 23 || minute > 59 || second > 59 + || (timezoneParts && (Number(timezoneParts[2]) > 23 || Number(timezoneParts[3]) > 59)) + ) { + throw new InvalidArgumentError('must be a valid ISO timestamp with a timezone'); + } + const schedule = new Date(value); if (Number.isNaN(schedule.getTime())) { - throw new InvalidArgumentError('must be a valid ISO timestamp'); + throw new InvalidArgumentError('must be a valid ISO timestamp with a timezone'); } return schedule; }