mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 15:39:37 +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:
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
expandTilde,
|
||||
@@ -1078,8 +1078,6 @@ export const AddDownloadsModal = () => {
|
||||
);
|
||||
|
||||
let count = 1;
|
||||
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
|
||||
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
|
||||
let newName = finalFile;
|
||||
let exists = true;
|
||||
const batchTargets: Array<{ location: string; fileName: string }> = [];
|
||||
@@ -1098,7 +1096,7 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
|
||||
while (exists && count < 1000) {
|
||||
newName = `${base} (${count})${ext}`;
|
||||
newName = downloadFileNameWithSuffix(finalFile, ` (${count})`);
|
||||
let storeHas = false;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
for (const download of useDownloadStore.getState().downloads) {
|
||||
|
||||
+6
-5
@@ -22,7 +22,8 @@
|
||||
--bg-input: 0 0% 100%;
|
||||
--border-modal: 0 0% 85%;
|
||||
--surface-raised: 0 0% 100%;
|
||||
--surface-overlay: 0 0% 100% / 0.95;
|
||||
/* Keep this token alpha-free because some consumers apply their own /alpha. */
|
||||
--surface-overlay: 0 0% 100%;
|
||||
--shadow-color: 220 10% 20% / 0.1;
|
||||
--sidebar-shell-bg: 0 0% 92%;
|
||||
--sidebar-panel-bg: 0 0% 96%;
|
||||
@@ -66,7 +67,7 @@
|
||||
--bg-input: 0 0% 100%;
|
||||
--border-modal: 0 0% 85%;
|
||||
--surface-raised: 0 0% 100%;
|
||||
--surface-overlay: 0 0% 100% / 0.95;
|
||||
--surface-overlay: 0 0% 100%;
|
||||
--shadow-color: 220 10% 20% / 0.1;
|
||||
--sidebar-shell-bg: 0 0% 92%;
|
||||
--sidebar-panel-bg: 0 0% 96%;
|
||||
@@ -911,7 +912,7 @@ html[data-list-density="relaxed"] {
|
||||
|
||||
.add-download-playlist-media-type-option.is-selected {
|
||||
background: hsl(var(--surface-raised) / 0.9);
|
||||
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35);
|
||||
box-shadow: 0 1px 2px hsl(var(--shadow-color));
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
@@ -2508,7 +2509,7 @@ html[data-list-density="relaxed"] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(var(--border-color));
|
||||
border-bottom-color: hsl(var(--border-color) / 0.72);
|
||||
border-bottom-color: hsl(var(--border-color));
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--item-hover));
|
||||
color: hsl(var(--text-secondary));
|
||||
@@ -3106,7 +3107,7 @@ body.is-queue-dragging * {
|
||||
|
||||
.add-download-nested-fields {
|
||||
padding-inline-start: 1.25rem;
|
||||
border-inline-start: 2px solid hsl(var(--border-modal) / 0.5);
|
||||
border-inline-start: 2px solid hsl(var(--border-modal));
|
||||
}
|
||||
|
||||
.add-download-advanced-fields {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dispatchItem, getProxyArgs, getSiteLogin, hasStaleTemporaryMediaEstimat
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
import { MAX_DOWNLOAD_FILENAME_BYTES } from '../utils/downloads';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
invokeCommand: vi.fn(),
|
||||
@@ -113,6 +114,27 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().pendingAddRequestVersion).toBe(initialVersion + 2);
|
||||
});
|
||||
|
||||
it('normalizes an overlong filename edited in Properties before persisting it', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'properties-long-name',
|
||||
url: 'https://example.com/video',
|
||||
fileName: 'video.mp4',
|
||||
status: 'ready',
|
||||
category: 'Movies',
|
||||
dateAdded: ''
|
||||
}] as any[]
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().applyProperties('properties-long-name', {
|
||||
fileName: `${'title '.repeat(100)}.mp4`
|
||||
});
|
||||
|
||||
const fileName = useDownloadStore.getState().downloads[0].fileName;
|
||||
expect(new TextEncoder().encode(fileName).length).toBeLessThanOrEqual(MAX_DOWNLOAD_FILENAME_BYTES);
|
||||
expect(fileName.endsWith('.mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces stale media intent when an appended handoff reuses a URL', () => {
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://example.com/file.bin', '', '', '', '', true
|
||||
@@ -1563,7 +1585,7 @@ describe('useDownloadStore', () => {
|
||||
})];
|
||||
}
|
||||
if (cmd === 'enqueue_many') {
|
||||
return [{ id: 'startup-accepted', success: true, filename: 'file.bin' }];
|
||||
return [{ id: 'startup-accepted', success: true, filename: 'normalized.bin' }];
|
||||
}
|
||||
if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
|
||||
if (cmd === 'resume_download') return true;
|
||||
@@ -1575,6 +1597,7 @@ describe('useDownloadStore', () => {
|
||||
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
fileName: 'normalized.bin',
|
||||
status: 'queued',
|
||||
hasBeenDispatched: true
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -809,13 +809,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const state = get();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return;
|
||||
const normalizedUpdates = updates.fileName === undefined
|
||||
? updates
|
||||
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
|
||||
|
||||
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
|
||||
throw new Error(i18n.t($ => $.downloadTable.transferActive));
|
||||
}
|
||||
|
||||
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
|
||||
state.updateDownload(id, updates);
|
||||
state.updateDownload(id, normalizedUpdates);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -828,7 +831,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
state.unregisterBackendIds([id]);
|
||||
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
state.updateDownload(id, normalizedUpdates);
|
||||
if (isRegistered || wasDispatching) {
|
||||
const dispatched = await dispatchItemInternal(id);
|
||||
if (dispatched) {
|
||||
@@ -847,7 +850,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
}
|
||||
state.unregisterBackendIds([id]);
|
||||
}
|
||||
state.updateDownload(id, updates);
|
||||
state.updateDownload(id, normalizedUpdates);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1306,21 +1309,25 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
addDownload: async (item, action) => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const destPath = await effectiveDestinationForItem(item, settings);
|
||||
const normalizedItem = {
|
||||
...item,
|
||||
fileName: canonicalizeDownloadFileName(item.fileName)
|
||||
};
|
||||
const destPath = await effectiveDestinationForItem(normalizedItem, settings);
|
||||
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
|
||||
const queueItems = get().downloads.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId
|
||||
);
|
||||
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
|
||||
const queuePosition = maxPos + 1;
|
||||
const { sizeBytes, ...downloadDraft } = item;
|
||||
const { sizeBytes, ...downloadDraft } = normalizedItem;
|
||||
const ownedItem: DownloadItem = {
|
||||
...downloadDraft,
|
||||
totalBytes: item.totalBytes ?? sizeBytes,
|
||||
totalIsEstimate: item.totalIsEstimate ?? (
|
||||
item.isMedia === true && item.size?.trim().startsWith('~')
|
||||
totalBytes: normalizedItem.totalBytes ?? sizeBytes,
|
||||
totalIsEstimate: normalizedItem.totalIsEstimate ?? (
|
||||
normalizedItem.isMedia === true && normalizedItem.size?.trim().startsWith('~')
|
||||
),
|
||||
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||
connections: resolveDownloadConnections(normalizedItem.connections, settings.perServerConnections),
|
||||
destination: destPath,
|
||||
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
|
||||
queueId,
|
||||
@@ -2055,6 +2062,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
.filter(result => !result.success)
|
||||
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
|
||||
);
|
||||
const acceptedFilenames = new Map(
|
||||
results
|
||||
.filter(result => result.success && Boolean(result.filename))
|
||||
.map(result => [result.id, result.filename!])
|
||||
);
|
||||
const acceptedIdSet = new Set(registeredIds);
|
||||
const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation]));
|
||||
|
||||
@@ -2089,7 +2101,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
lastError: failedErrors.get(download.id)
|
||||
}
|
||||
: liveAcceptedIds.has(download.id)
|
||||
? { ...download, hasBeenDispatched: true, lastError: undefined }
|
||||
? {
|
||||
...download,
|
||||
...(acceptedFilenames.has(download.id)
|
||||
? {
|
||||
fileName: acceptedFilenames.get(download.id),
|
||||
category: categoryForFileName(acceptedFilenames.get(download.id)!)
|
||||
}
|
||||
: {}),
|
||||
hasBeenDispatched: true,
|
||||
lastError: undefined
|
||||
}
|
||||
: download
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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