Безопасный парсинг строк

Парсинг строк дат — наиболее опасная операция при работе с timeago.js. Разные браузеры интерпретируют одни и те же строки по-разному, а вредоносный ввод может привести к неожиданному поведению.


Проблема нестандартного парсинга

// Эти строки дают разные результаты в разных браузерах
new Date('2025-06-01');            // UTC или локальное — зависит от браузера
new Date('June 1, 2025');          // Работает в Chrome, может не работать в Safari
new Date('01/06/2025');            // Неоднозначно: 1 июня или 6 января?
new Date('2025.06.01');            // Нестандартно
new Date('1 июня 2025');           // Зависит от локали браузера

Надёжные форматы для парсинга

// Всегда работают корректно в современных браузерах
new Date('2025-06-01T12:00:00Z');          // ISO 8601 с Z — UTC
new Date('2025-06-01T12:00:00+03:00');     // ISO 8601 с offset
new Date(1748779200000);                    // timestamp в миллисекундах

Функция безопасного парсинга

type ParseResult =
  | { ok: true;  date: Date }
  | { ok: false; error: string };

function parseDate(input: unknown): ParseResult {
  // Уже Date — проверить валидность
  if (input instanceof Date) {
    return isNaN(input.getTime())
      ? { ok: false, error: 'Invalid Date object' }
      : { ok: true, date: input };
  }

  // Число — timestamp
  if (typeof input === 'number') {
    if (!isFinite(input)) {
      return { ok: false, error: `Non-finite number: ${input}` };
    }
    // Автоопределение: секунды или миллисекунды
    const ts = input < 1e10 ? input * 1000 : input;
    return { ok: true, date: new Date(ts) };
  }

  // Строка — нормализовать и парсить
  if (typeof input === 'string') {
    return parseString(input);
  }

  return { ok: false, error: `Unsupported type: ${typeof input}` };
}

function parseString(str: string): ParseResult {
  const trimmed = str.trim();

  if (!trimmed) {
    return { ok: false, error: 'Empty string' };
  }

  // Нормализация: добавить Z если отсутствует timezone
  const normalized = normalizeTimeZone(trimmed);

  const d = new Date(normalized);

  if (isNaN(d.getTime())) {
    return { ok: false, error: `Cannot parse: "${str}"` };
  }

  return { ok: true, date: d };
}

Нормализация часовых поясов

function normalizeTimeZone(str: string): string {
  // Строка формата YYYY-MM-DD без времени — добавить T00:00:00Z
  if (/^\d{4}-\d{2}-\d{2}$/.test(str)) {
    return str + 'T00:00:00Z';
  }

  // Строка с T, но без timezone — добавить Z
  if (
    str.includes('T') &&
    !str.endsWith('Z') &&
    !/[+\-]\d{2}:\d{2}$/.test(str)
  ) {
    return str + 'Z';
  }

  return str;
}

Защита от слишком длинных строк

const MAX_DATE_STRING_LENGTH = 50;

function parseStringSafe(str: string): ParseResult {
  if (str.length > MAX_DATE_STRING_LENGTH) {
    return { ok: false, error: `String too long: ${str.length} characters` };
  }

  // Только ASCII символы в строках дат
  if (/[^\x00-\x7F]/.test(str)) {
    return { ok: false, error: 'Non-ASCII characters in date string' };
  }

  return parseString(str);
}

Обёртка format с безопасным парсингом

import { format } from 'timeago.js';

function secureFormat(input: unknown, locale = 'ru', fallback = ''): string {
  const result = parseDate(input);

  if (!result.ok) {
    return fallback;
  }

  try {
    return format(result.date, locale);
  } catch (err) {
    // Маловероятно, но format может бросить в экстремальных случаях
    return fallback;
  }
}

Парсинг дат в объектах API-ответа

interface RawPost {
  id:        number;
  title:     string;
  createdAt: unknown; // Тип из API неизвестен
}

interface SafePost {
  id:        number;
  title:     string;
  createdAt: Date;
}

function sanitizePost(raw: RawPost): SafePost | null {
  const dateResult = parseDate(raw.createdAt);

  if (!dateResult.ok) {
    console.error(`Invalid date in post ${raw.id}: ${dateResult.error}`);
    return null;
  }

  return {
    id:        raw.id,
    title:     raw.title,
    createdAt: dateResult.date,
  };
}

Тестирование парсера

describe('parseDate', () => {
  it('принимает ISO строку с Z', () => {
    expect(parseDate('2025-06-01T12:00:00Z').ok).toBe(true);
  });

  it('принимает timestamp в мс', () => {
    expect(parseDate(Date.now()).ok).toBe(true);
  });

  it('принимает нативный Date', () => {
    expect(parseDate(new Date()).ok).toBe(true);
  });

  it('отклоняет null', () => {
    expect(parseDate(null).ok).toBe(false);
  });

  it('отклоняет пустую строку', () => {
    expect(parseDate('').ok).toBe(false);
  });

  it('отклоняет невалидную строку', () => {
    expect(parseDate('not-a-date').ok).toBe(false);
  });

  it('нормализует строку без timezone', () => {
    const result = parseDate('2025-06-01T12:00:00');
    expect(result.ok).toBe(true);
  });

  it('нормализует строку только с датой', () => {
    const result = parseDate('2025-06-01');
    expect(result.ok).toBe(true);
  });
});

Поддерживаемые форматы

Формат Статус Примечание
2025-06-01T12:00:00Z Предпочтительный
2025-06-01T12:00:00+03:00 Явный offset
2025-06-01 ✓ (нормализуется) Добавляется T00:00:00Z
1748779200000 timestamp в мс
2025/06/01 Осторожно Нестандартно
01.06.2025 Отклонить явно
June 1, 2025 Осторожно Зависит от браузера