mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 03:59:09 +00:00
fix(downloads): harden filenames and localized surfaces
Bound generated filenames to cross-platform component limits, preserve extensions, and keep duplicate renames unique. Correct light-theme compositing tokens and enforce Persian date formatting while preserving Hebrew locale formatting. Fixes #29 Refs #31
This commit is contained in:
@@ -17,13 +17,45 @@ describe('date/time formatting', () => {
|
||||
});
|
||||
|
||||
it('supports the opt-in Persian and Hebrew calendars', () => {
|
||||
const options: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian', options })).toBe(
|
||||
new Intl.DateTimeFormat('fa-u-ca-persian', options).format(instant)
|
||||
const hebrewOptions: Intl.DateTimeFormatOptions = { dateStyle: 'long' };
|
||||
expect(formatDateTime(instant, { locale: 'fa', calendar: 'persian' })).toBe('۱۴۰۵/۰۱/۰۱');
|
||||
expect(formatDateTime(instant, {
|
||||
locale: 'fa',
|
||||
calendar: 'persian',
|
||||
options: { dateStyle: 'medium', timeStyle: 'short' }
|
||||
})).toContain('۱۴۰۵/۰۱/۰۱');
|
||||
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options: hebrewOptions })).toBe(
|
||||
new Intl.DateTimeFormat('he-u-ca-hebrew', hebrewOptions).format(instant)
|
||||
);
|
||||
expect(formatDateTime(instant, { locale: 'he', calendar: 'hebrew', options })).toBe(
|
||||
new Intl.DateTimeFormat('he-u-ca-hebrew', options).format(instant)
|
||||
});
|
||||
|
||||
it('keeps Persian dates zero-padded when the caller requests weekday or time', () => {
|
||||
const formatted = formatDateTime(instant, {
|
||||
locale: 'fa',
|
||||
calendar: 'persian',
|
||||
options: {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
}
|
||||
});
|
||||
|
||||
expect(formatted).toContain('۱۴۰۵/۰۱/۰۱');
|
||||
});
|
||||
|
||||
it('does not drop Persian time-only Intl fields', () => {
|
||||
const formatted = formatDateTime(
|
||||
new Date('2026-03-21T14:30:00.123Z'),
|
||||
{
|
||||
locale: 'en',
|
||||
calendar: 'persian',
|
||||
options: { fractionalSecondDigits: 3 } as Intl.DateTimeFormatOptions
|
||||
}
|
||||
);
|
||||
|
||||
expect(formatted).toContain('123');
|
||||
});
|
||||
|
||||
it('returns a safe placeholder for malformed timestamps and rejects unknown preferences', () => {
|
||||
|
||||
@@ -28,6 +28,100 @@ const localeWithCalendar = (locale: string | null | undefined, calendar: Calenda
|
||||
const dateFromInput = (value: DateTimeInput): Date =>
|
||||
value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||
|
||||
const DATE_OPTION_KEYS = new Set([
|
||||
'dateStyle',
|
||||
'era',
|
||||
'month',
|
||||
'day',
|
||||
'year',
|
||||
'weekday'
|
||||
]);
|
||||
|
||||
const TIME_OPTION_KEYS = [
|
||||
'hour',
|
||||
'hour12',
|
||||
'hourCycle',
|
||||
'minute',
|
||||
'second',
|
||||
'timeZoneName',
|
||||
'dayPeriod',
|
||||
'fractionalSecondDigits'
|
||||
] as const;
|
||||
|
||||
const persianDateParts = (
|
||||
date: Date,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
calendar: 'persian',
|
||||
numberingSystem: options.numberingSystem,
|
||||
timeZone: options.timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
const values = new Map(formatter.formatToParts(date)
|
||||
.filter(part => part.type === 'year' || part.type === 'month' || part.type === 'day')
|
||||
.map(part => [part.type, part.value]));
|
||||
const year = values.get('year');
|
||||
const month = values.get('month');
|
||||
const day = values.get('day');
|
||||
if (!year || !month || !day) return formatter.format(date);
|
||||
return `${year}/${month}/${day}`;
|
||||
};
|
||||
|
||||
const formatPersianDateTime = (
|
||||
date: Date,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const hasDateOptions = Object.keys(options).some(key => DATE_OPTION_KEYS.has(key));
|
||||
const hasTimeOptions = options.timeStyle !== undefined ||
|
||||
TIME_OPTION_KEYS.some(key => (options as unknown as Record<string, unknown>)[key] !== undefined);
|
||||
const includeDate = Object.keys(options).length === 0 || hasDateOptions;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (includeDate) {
|
||||
let dateText = persianDateParts(date, locale, options);
|
||||
if (options.weekday) {
|
||||
const weekday = new Intl.DateTimeFormat(locale, {
|
||||
calendar: 'persian',
|
||||
numberingSystem: options.numberingSystem,
|
||||
timeZone: options.timeZone,
|
||||
weekday: options.weekday
|
||||
}).format(date);
|
||||
dateText = `${weekday}, ${dateText}`;
|
||||
}
|
||||
parts.push(dateText);
|
||||
}
|
||||
|
||||
if (hasTimeOptions) {
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
calendar: 'persian',
|
||||
numberingSystem: options.numberingSystem,
|
||||
timeZone: options.timeZone
|
||||
};
|
||||
for (const key of TIME_OPTION_KEYS) {
|
||||
const value = (options as unknown as Record<string, unknown>)[key];
|
||||
if (value !== undefined) Object.assign(timeOptions, { [key]: value });
|
||||
}
|
||||
if (options.timeStyle) {
|
||||
timeOptions.hour = 'numeric';
|
||||
timeOptions.minute = '2-digit';
|
||||
if (options.timeStyle === 'medium' || options.timeStyle === 'long' || options.timeStyle === 'full') {
|
||||
timeOptions.second = '2-digit';
|
||||
}
|
||||
if (options.timeStyle === 'long' || options.timeStyle === 'full') {
|
||||
timeOptions.timeZoneName = options.timeStyle === 'full' ? 'long' : 'short';
|
||||
}
|
||||
}
|
||||
parts.push(new Intl.DateTimeFormat(locale, timeOptions).format(date));
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a user-facing timestamp with an explicit calendar. Gregorian is
|
||||
* passed explicitly because some localized browser defaults use a regional
|
||||
@@ -46,6 +140,13 @@ export const formatDateTime = (
|
||||
const options = config.options ?? {};
|
||||
|
||||
try {
|
||||
if (calendar === 'persian') {
|
||||
return formatPersianDateTime(
|
||||
date,
|
||||
localeWithCalendar(config.locale, calendar),
|
||||
options
|
||||
);
|
||||
}
|
||||
return new Intl.DateTimeFormat(
|
||||
localeWithCalendar(config.locale, calendar),
|
||||
options
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import {
|
||||
downloadFileNamesMatch,
|
||||
downloadFileNameWithSuffix,
|
||||
downloadMediaKindsMatch,
|
||||
MAX_DOWNLOAD_FILENAME_BYTES,
|
||||
canonicalizeDownloadFileName,
|
||||
redactDownloadForPersistence,
|
||||
resolveDownloadConnections
|
||||
} from './downloads';
|
||||
@@ -63,6 +66,29 @@ describe('download connection resolution', () => {
|
||||
});
|
||||
|
||||
describe('download filename matching', () => {
|
||||
it('truncates long names by UTF-8 bytes while preserving the extension', () => {
|
||||
const filename = canonicalizeDownloadFileName(`${'title '.repeat(100)}.mp4`);
|
||||
|
||||
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(filename.endsWith('.mp4')).toBe(true);
|
||||
expect(filename).toContain('…');
|
||||
});
|
||||
|
||||
it('does not split a multibyte character at the filesystem boundary', () => {
|
||||
const filename = canonicalizeDownloadFileName(`${'😀'.repeat(100)}.mkv`);
|
||||
|
||||
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(filename.endsWith('.mkv')).toBe(true);
|
||||
expect([...filename].every(character => character !== '\uFFFD')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps alternate names unique and bounded after long-name truncation', () => {
|
||||
const filename = downloadFileNameWithSuffix(`${'title '.repeat(100)}.mp4`, ' (1)');
|
||||
|
||||
expect(new TextEncoder().encode(filename).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(filename.endsWith(' (1).mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches case and path spelling while preserving the actual filename', () => {
|
||||
expect(downloadFileNamesMatch(
|
||||
'Media\\Example.Show.S01E01.MKV',
|
||||
|
||||
+65
-2
@@ -43,6 +43,27 @@ export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
export const DOWNLOAD_CONNECTIONS_MIN = 1;
|
||||
export const DOWNLOAD_CONNECTIONS_MAX = 16;
|
||||
|
||||
// Keep every filename component within the common cross-platform filesystem
|
||||
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
|
||||
// bound is also conservative for Windows filename components.
|
||||
export const MAX_DOWNLOAD_FILENAME_BYTES = 255;
|
||||
const FILENAME_TRUNCATION_MARKER = '…';
|
||||
|
||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length;
|
||||
|
||||
const truncateUtf8ToBytes = (value: string, maxBytes: number): string => {
|
||||
if (maxBytes <= 0) return '';
|
||||
let bytes = 0;
|
||||
let result = '';
|
||||
for (const character of value) {
|
||||
const characterBytes = utf8ByteLength(character);
|
||||
if (bytes + characterBytes > maxBytes) break;
|
||||
result += character;
|
||||
bytes += characterBytes;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve persisted/user-entered connection values before they cross into the
|
||||
* backend. Older rows may omit the value, while malformed rows can contain
|
||||
@@ -126,10 +147,52 @@ export const fileNameFromUrl = (rawUrl: string): string => {
|
||||
export const canonicalizeDownloadFileName = (fileName: string): string => {
|
||||
const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download';
|
||||
const sanitized = leaf
|
||||
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-')
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
|
||||
.trim()
|
||||
.replace(/[. ]+$/g, '');
|
||||
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
||||
const canonical = sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
|
||||
if (utf8ByteLength(canonical) <= MAX_DOWNLOAD_FILENAME_BYTES) return canonical;
|
||||
|
||||
const extensionStart = canonical.lastIndexOf('.');
|
||||
const hasExtension = extensionStart > 0;
|
||||
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
|
||||
const extension = hasExtension ? canonical.slice(extensionStart) : '';
|
||||
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
|
||||
- utf8ByteLength(extension)
|
||||
- utf8ByteLength(FILENAME_TRUNCATION_MARKER);
|
||||
|
||||
if (baseBudget <= 0) {
|
||||
return truncateUtf8ToBytes(canonical, MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
}
|
||||
|
||||
return `${truncateUtf8ToBytes(base, baseBudget)}${FILENAME_TRUNCATION_MARKER}${extension}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a deterministic alternate filename without exceeding the same
|
||||
* component limit as canonicalizeDownloadFileName. The suffix is intended for
|
||||
* trusted generated values such as " (1)".
|
||||
*/
|
||||
export const downloadFileNameWithSuffix = (fileName: string, suffix: string): string => {
|
||||
const canonical = canonicalizeDownloadFileName(fileName);
|
||||
const safeSuffix = suffix
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f<>:"/\\|?*]/g, '-')
|
||||
.replace(/[. ]+$/g, '');
|
||||
if (!safeSuffix.trim()) return canonical;
|
||||
|
||||
const extensionStart = canonical.lastIndexOf('.');
|
||||
const hasExtension = extensionStart > 0;
|
||||
const base = hasExtension ? canonical.slice(0, extensionStart) : canonical;
|
||||
const extension = hasExtension ? canonical.slice(extensionStart) : '';
|
||||
const baseBudget = MAX_DOWNLOAD_FILENAME_BYTES
|
||||
- utf8ByteLength(safeSuffix)
|
||||
- utf8ByteLength(extension);
|
||||
|
||||
if (baseBudget <= 0) {
|
||||
return truncateUtf8ToBytes(`${base}${safeSuffix}${extension}`, MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
}
|
||||
|
||||
return `${truncateUtf8ToBytes(base, baseBudget)}${safeSuffix}${extension}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user