mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 15:46:17 +00:00
fix(settings): harden locations and site login matching
This commit is contained in:
@@ -22,7 +22,9 @@ import appIcon from '../assets/app-icon.png';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||||
DOWNLOAD_CATEGORIES,
|
DOWNLOAD_CATEGORIES,
|
||||||
normalizeCategorySubfolder
|
formatDerivedCategoryPath,
|
||||||
|
normalizeCategorySubfolder,
|
||||||
|
subfolderFromDerivedCategoryPath
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
import { usePlatformInfo } from '../utils/platform';
|
import { usePlatformInfo } from '../utils/platform';
|
||||||
|
|
||||||
@@ -146,10 +148,10 @@ const CategoryFolderInput = ({
|
|||||||
settings: SettingsState;
|
settings: SettingsState;
|
||||||
onBrowse: () => void;
|
onBrowse: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const base = settings.baseDownloadFolder.replace(/\/+$/, '') || '~/Downloads';
|
const base = settings.baseDownloadFolder || '~/Downloads';
|
||||||
const sub = settings.categorySubfolders[category] || DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS];
|
const sub = settings.categorySubfolders[category] || DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS];
|
||||||
const override = settings.categoryDirectoryOverrides[category];
|
const override = settings.categoryDirectoryOverrides[category];
|
||||||
const displayPath = override ?? `${base}/${sub}`;
|
const displayPath = override ?? formatDerivedCategoryPath(base, sub);
|
||||||
|
|
||||||
const [localValue, setLocalValue] = useState<string | null>(null);
|
const [localValue, setLocalValue] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -164,17 +166,17 @@ const CategoryFolderInput = ({
|
|||||||
onChange={(e) => setLocalValue(e.target.value)}
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const val = localValue ?? displayPath;
|
const val = localValue ?? displayPath;
|
||||||
const basePrefix = base + '/';
|
const derivedSubfolder = subfolderFromDerivedCategoryPath(val, base);
|
||||||
|
|
||||||
if (!val.trim()) {
|
if (!val.trim()) {
|
||||||
settings.setCategoryDirectoryOverride(category, undefined);
|
settings.setCategoryDirectoryOverride(category, undefined);
|
||||||
settings.setCategorySubfolder(category, '');
|
settings.setCategorySubfolder(category, '');
|
||||||
} else if (val.startsWith(basePrefix)) {
|
} else if (derivedSubfolder !== null) {
|
||||||
settings.setCategoryDirectoryOverride(category, undefined);
|
settings.setCategoryDirectoryOverride(category, undefined);
|
||||||
settings.setCategorySubfolder(
|
settings.setCategorySubfolder(
|
||||||
category,
|
category,
|
||||||
normalizeCategorySubfolder(
|
normalizeCategorySubfolder(
|
||||||
val.substring(basePrefix.length),
|
derivedSubfolder,
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS]
|
DEFAULT_CATEGORY_SUBFOLDERS[category as keyof typeof DEFAULT_CATEGORY_SUBFOLDERS]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -867,6 +869,9 @@ runEngineChecks(false);
|
|||||||
type="text"
|
type="text"
|
||||||
value={settings.baseDownloadFolder}
|
value={settings.baseDownloadFolder}
|
||||||
onChange={(e) => settings.setBaseDownloadFolder(e.target.value)}
|
onChange={(e) => settings.setBaseDownloadFolder(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
settings.setBaseDownloadFolder(settings.baseDownloadFolder.trim() || '~/Downloads');
|
||||||
|
}}
|
||||||
className="app-control w-64 text-[11px] px-2"
|
className="app-control w-64 text-[11px] px-2"
|
||||||
placeholder="~/Downloads"
|
placeholder="~/Downloads"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { getProxyArgs, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
||||||
import { useSettingsStore } from './useSettingsStore';
|
import { useSettingsStore } from './useSettingsStore';
|
||||||
import * as ipc from '../ipc';
|
import * as ipc from '../ipc';
|
||||||
|
|
||||||
@@ -90,6 +90,25 @@ describe('useDownloadStore', () => {
|
|||||||
} as ReturnType<typeof useSettingsStore.getState>)).toBe('socks5://127.0.0.1:1080');
|
} as ReturnType<typeof useSettingsStore.getState>)).toBe('socks5://127.0.0.1:1080');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('matches site logins by host, wildcard host, path, and full URL patterns', () => {
|
||||||
|
const settings = {
|
||||||
|
siteLogins: [
|
||||||
|
{ id: 'host', urlPattern: 'example.com', username: 'host' },
|
||||||
|
{ id: 'wildcard', urlPattern: '*.cdn.example.com', username: 'wildcard' },
|
||||||
|
{ id: 'broad', urlPattern: '*.example.com', username: 'broad' },
|
||||||
|
{ id: 'path', urlPattern: 'secure.example.com/private/*', username: 'path' },
|
||||||
|
{ id: 'url', urlPattern: 'https://downloads.example.net/releases/*', username: 'url' }
|
||||||
|
]
|
||||||
|
} as ReturnType<typeof useSettingsStore.getState>;
|
||||||
|
|
||||||
|
expect(getSiteLogin('https://example.com/file.zip', settings)?.id).toBe('host');
|
||||||
|
expect(getSiteLogin('https://assets.cdn.example.com/file.zip', settings)?.id).toBe('wildcard');
|
||||||
|
expect(getSiteLogin('https://secure.example.com/private/file.zip', settings)?.id).toBe('path');
|
||||||
|
expect(getSiteLogin('https://downloads.example.net/releases/app.zip', settings)?.id).toBe('url');
|
||||||
|
expect(getSiteLogin('https://secure.example.com/public/file.zip', settings)?.id).toBe('broad');
|
||||||
|
expect(getSiteLogin('https://unrelated.example.org/public/file.zip', settings)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => {
|
it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
|
|||||||
@@ -124,24 +124,64 @@ export const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeRegex = (value: string): string =>
|
||||||
|
value.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const wildcardToRegex = (pattern: string): RegExp =>
|
||||||
|
new RegExp(`^${escapeRegex(pattern).replace(/\*+/g, '.*')}$`);
|
||||||
|
|
||||||
|
const patternSpecificity = (pattern: string): number =>
|
||||||
|
pattern.replace(/\*/g, '').length;
|
||||||
|
|
||||||
|
const hostPatternScore = (pattern: string, host: string): number | null => {
|
||||||
|
if (pattern.startsWith('*.')) {
|
||||||
|
const suffix = pattern.substring(2);
|
||||||
|
return host === suffix || host.endsWith(`.${suffix}`)
|
||||||
|
? 1000 + patternSpecificity(pattern)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
return wildcardToRegex(pattern).test(host)
|
||||||
|
? 1000 + patternSpecificity(pattern)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return host === pattern ? 2000 + patternSpecificity(pattern) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlPatternScore = (pattern: string, url: URL): number | null => {
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(pattern)) {
|
||||||
|
const normalizedPattern = pattern.toLowerCase().replace(/\/+$/, '');
|
||||||
|
const normalizedUrl = url.toString().toLowerCase().replace(/\/+$/, '');
|
||||||
|
return wildcardToRegex(normalizedPattern).test(normalizedUrl)
|
||||||
|
? 4000 + patternSpecificity(normalizedPattern)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern.includes('/')) {
|
||||||
|
const normalizedPattern = pattern.toLowerCase().replace(/^\/+/, '');
|
||||||
|
const normalizedTarget = `${url.hostname}${url.pathname}`.toLowerCase().replace(/\/+$/, '');
|
||||||
|
return wildcardToRegex(normalizedPattern).test(normalizedTarget)
|
||||||
|
? 3000 + patternSpecificity(normalizedPattern)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostPatternScore(pattern, url.hostname.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
export const getSiteLogin = (url: string, settings: ReturnType<typeof useSettingsStore.getState>) => {
|
export const getSiteLogin = (url: string, settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const host = urlObj.hostname.toLowerCase();
|
let bestMatch: { login: typeof settings.siteLogins[number]; score: number } | null = null;
|
||||||
for (const login of settings.siteLogins) {
|
for (const login of settings.siteLogins) {
|
||||||
let pattern = login.urlPattern.toLowerCase().trim();
|
const pattern = login.urlPattern.toLowerCase().trim();
|
||||||
if (pattern.startsWith('*.')) {
|
const score = pattern ? urlPatternScore(pattern, urlObj) : null;
|
||||||
const suffix = pattern.substring(2);
|
if (score !== null && (!bestMatch || score > bestMatch.score)) {
|
||||||
if (host === suffix || host.endsWith('.' + suffix)) return login;
|
bestMatch = { login, score };
|
||||||
} else if (pattern.includes('*')) {
|
|
||||||
const collapsed = pattern.replace(/\*+/g, '*');
|
|
||||||
const escaped = collapsed.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$');
|
|
||||||
if (regex.test(host)) return login;
|
|
||||||
} else if (host === pattern) {
|
|
||||||
return login;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return bestMatch?.login ?? null;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ vi.mock('@tauri-apps/api/path', () => ({
|
|||||||
import {
|
import {
|
||||||
downloadLocationEquals,
|
downloadLocationEquals,
|
||||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||||
|
formatDerivedCategoryPath,
|
||||||
normalizeCategorySubfolder,
|
normalizeCategorySubfolder,
|
||||||
normalizeDownloadLocationSettings,
|
normalizeDownloadLocationSettings,
|
||||||
resolveCategoryDestination
|
resolveCategoryDestination,
|
||||||
|
subfolderFromDerivedCategoryPath
|
||||||
} from './downloadLocations';
|
} from './downloadLocations';
|
||||||
|
|
||||||
describe('download locations', () => {
|
describe('download locations', () => {
|
||||||
@@ -68,4 +70,21 @@ describe('download locations', () => {
|
|||||||
expect(normalizeCategorySubfolder('C:\\Media\\Movies', 'Movies')).toBe('Media/Movies');
|
expect(normalizeCategorySubfolder('C:\\Media\\Movies', 'Movies')).toBe('Media/Movies');
|
||||||
expect(normalizeCategorySubfolder('../../', 'Movies')).toBe('Movies');
|
expect(normalizeCategorySubfolder('../../', 'Movies')).toBe('Movies');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats and parses derived category paths with platform path separators', () => {
|
||||||
|
expect(formatDerivedCategoryPath('/Users/test/Downloads', 'Video Files'))
|
||||||
|
.toBe('/Users/test/Downloads/Video Files');
|
||||||
|
expect(formatDerivedCategoryPath('D:\\Downloads', 'Video Files'))
|
||||||
|
.toBe('D:\\Downloads\\Video Files');
|
||||||
|
expect(subfolderFromDerivedCategoryPath(
|
||||||
|
'D:\\Downloads\\Video Files',
|
||||||
|
'd:\\downloads'
|
||||||
|
)).toBe('Video Files');
|
||||||
|
expect(subfolderFromDerivedCategoryPath(
|
||||||
|
'/Users/test/Downloads/Video Files',
|
||||||
|
'/Users/test/Downloads'
|
||||||
|
)).toBe('Video Files');
|
||||||
|
expect(subfolderFromDerivedCategoryPath('/Volumes/Media', '/Users/test/Downloads'))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,31 @@ const normalizedForComparison = (value: string): string =>
|
|||||||
const legacyDerivedPath = (base: string, subfolder: string): string =>
|
const legacyDerivedPath = (base: string, subfolder: string): string =>
|
||||||
`${normalizedForComparison(base)}/${subfolder.replace(/^[\\/]+|[\\/]+$/g, '')}`;
|
`${normalizedForComparison(base)}/${subfolder.replace(/^[\\/]+|[\\/]+$/g, '')}`;
|
||||||
|
|
||||||
|
const isWindowsLikePath = (value: string): boolean =>
|
||||||
|
/^[a-z]:[\\/]/i.test(value) || value.startsWith('\\\\') || value.includes('\\');
|
||||||
|
|
||||||
|
export const formatDerivedCategoryPath = (base: string, subfolder: string): string => {
|
||||||
|
const trimmedBase = base.trim() || '~/Downloads';
|
||||||
|
const relative = subfolder.replace(/^[\\/]+|[\\/]+$/g, '');
|
||||||
|
const separator = isWindowsLikePath(trimmedBase) ? '\\' : '/';
|
||||||
|
return `${trimmedBase.replace(/[\\/]+$/, '')}${separator}${relative}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subfolderFromDerivedCategoryPath = (
|
||||||
|
value: string,
|
||||||
|
base: string
|
||||||
|
): string | null => {
|
||||||
|
const normalizedValue = normalizedForComparison(value.trim());
|
||||||
|
const normalizedBase = normalizedForComparison((base.trim() || '~/Downloads'));
|
||||||
|
const isWindows = isWindowsLikePath(base);
|
||||||
|
const comparableValue = isWindows ? normalizedValue.toLocaleLowerCase() : normalizedValue;
|
||||||
|
const comparableBase = isWindows ? normalizedBase.toLocaleLowerCase() : normalizedBase;
|
||||||
|
|
||||||
|
if (comparableValue === comparableBase) return '';
|
||||||
|
if (!comparableValue.startsWith(`${comparableBase}/`)) return null;
|
||||||
|
return normalizedValue.slice(normalizedBase.length + 1);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeCategorySubfolder = (
|
export const normalizeCategorySubfolder = (
|
||||||
value: string,
|
value: string,
|
||||||
fallback: string
|
fallback: string
|
||||||
|
|||||||
Reference in New Issue
Block a user