Compare commits

...

4 Commits

12 changed files with 244 additions and 280 deletions
+17 -2
View File
@@ -64,6 +64,8 @@ interface QuotaCardProps<TState extends QuotaStatusState> {
cardIdleMessageKey?: string;
cardClassName: string;
defaultType: string;
canRefresh?: boolean;
onRefresh?: () => void;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
@@ -75,6 +77,8 @@ export function QuotaCard<TState extends QuotaStatusState>({
cardIdleMessageKey,
cardClassName,
defaultType,
canRefresh = false,
onRefresh,
renderQuotaItems
}: QuotaCardProps<TState>) {
const { t } = useTranslation();
@@ -90,7 +94,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const idleMessageKey = cardIdleMessageKey ?? `${i18nPrefix}.idle`;
const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
@@ -120,7 +124,18 @@ export function QuotaCard<TState extends QuotaStatusState>({
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
onRefresh ? (
<button
type="button"
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
onClick={onRefresh}
disabled={!canRefresh}
>
{t(idleMessageKey)}
</button>
) : (
<div className={styles.quotaMessage}>{t(idleMessageKey)}</div>
)
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
+50 -5
View File
@@ -8,8 +8,9 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores';
import { useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { getStatusFromError } from '@/utils/quota';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
@@ -105,6 +106,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const showNotification = useNotificationStore((state) => state.showNotification);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
@@ -202,6 +204,39 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
});
}, [filteredFiles, loading, setQuota]);
const refreshQuotaForFile = useCallback(
async (file: AuthFileItem) => {
if (disabled || file.disabled) return;
if (quota[file.name]?.status === 'loading') return;
setQuota((prev) => ({
...prev,
[file.name]: config.buildLoadingState()
}));
try {
const data = await config.fetchQuota(file, t);
setQuota((prev) => ({
...prev,
[file.name]: config.buildSuccessState(data)
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
setQuota((prev) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
}));
showNotification(
t('auth_files.quota_refresh_failed', { name: file.name, message }),
'error'
);
}
},
[config, disabled, quota, setQuota, showNotification, t]
);
const titleNode = (
<div className={styles.titleWrapper}>
<span>{t(`${config.i18nPrefix}.title`)}</span>
@@ -222,15 +257,21 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
<div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
variant="secondary"
size="sm"
className={`${styles.viewModeButton} ${
effectiveViewMode === 'paged' ? styles.viewModeButtonActive : ''
}`}
onClick={() => setViewMode('paged')}
>
{t('auth_files.view_mode_paged')}
</Button>
<Button
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
variant="secondary"
size="sm"
className={`${styles.viewModeButton} ${
effectiveViewMode === 'all' ? styles.viewModeButtonActive : ''
}`}
onClick={() => {
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
setShowTooManyWarning(true);
@@ -245,13 +286,15 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
<Button
variant="secondary"
size="sm"
className={styles.refreshAllButton}
onClick={handleRefresh}
disabled={disabled || isRefreshing}
loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
title={t('quota_management.refresh_all_credentials')}
aria-label={t('quota_management.refresh_all_credentials')}
>
{!isRefreshing && <IconRefreshCw size={16} />}
{t('quota_management.refresh_all_credentials')}
</Button>
</div>
}
@@ -274,6 +317,8 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
cardIdleMessageKey={config.cardIdleMessageKey}
cardClassName={config.cardClassName}
defaultType={config.type}
canRefresh={!disabled && !item.disabled}
onRefresh={() => void refreshQuotaForFile(item)}
renderQuotaItems={config.renderQuotaItems}
/>
))}
+27 -5
View File
@@ -84,6 +84,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
const QUOTA_PROGRESS_HIGH_THRESHOLD = 70;
const QUOTA_PROGRESS_MEDIUM_THRESHOLD = 30;
const geminiCliSupplementaryRequestIds = new Map<string, number>();
const geminiCliSupplementaryCache = new Map<
string,
@@ -721,7 +723,11 @@ const renderAntigravityItems = (
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
h(QuotaProgressBar, {
percent,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
});
};
@@ -795,7 +801,11 @@ const renderCodexItems = (
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
h(QuotaProgressBar, {
percent: remaining,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
})
);
@@ -886,7 +896,11 @@ const renderGeminiCliItems = (
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
h(QuotaProgressBar, {
percent,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
})
);
@@ -1078,7 +1092,11 @@ const renderClaudeItems = (
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
h(QuotaProgressBar, {
percent: remaining,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
})
);
@@ -1293,7 +1311,11 @@ const renderKimiItems = (
: null
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 60, mediumThreshold: 20 })
h(QuotaProgressBar, {
percent: remaining,
highThreshold: QUOTA_PROGRESS_HIGH_THRESHOLD,
mediumThreshold: QUOTA_PROGRESS_MEDIUM_THRESHOLD,
})
);
});
};
-1
View File
@@ -7,7 +7,6 @@ export type AuthFilesUiState = {
problemOnly?: boolean;
compactMode?: boolean;
search?: string;
regexSearchMode?: boolean;
page?: number;
pageSize?: number;
regularPageSize?: number;
+3 -6
View File
@@ -497,15 +497,11 @@
"pagination_next": "Next",
"pagination_info": "Page {{current}} / {{total}} · {{count}} files",
"search_label": "Search configs",
"search_placeholder": "Filter by name, type, or provider",
"search_regex_placeholder": "Match name, type, or provider with a regex",
"search_regex_invalid": "Enter a valid regex pattern (max {{max}} characters)",
"search_regex_unsafe": "This regex may freeze the page and has been blocked (avoid nested quantifiers, alternation in repeated groups, or backreferences)",
"search_placeholder": "Filter by name, type, or provider. Use * as a wildcard",
"problem_filter_label": "Problem Filter",
"problem_filter_only": "Only show problematic credentials",
"display_options_label": "Display options",
"compact_mode_label": "Compact mode",
"regex_search_mode_label": "Regex mode",
"sort_label": "Sort",
"sort_default": "Default",
"sort_az": "A-Z Name",
@@ -1347,7 +1343,8 @@
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files",
"refresh_files_and_quota": "Refresh files & quota",
"card_idle_hint": "Use the top \"Refresh files & quota\" button to fetch the latest quota data."
"refresh_all_credentials": "Refresh all credentials",
"card_idle_hint": "Use the top \"Refresh all credentials\" button to fetch the latest quota data."
},
"system_info": {
"title": "Management Center Info",
+3 -6
View File
@@ -497,15 +497,11 @@
"pagination_next": "Следующая",
"pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов",
"search_label": "Поиск конфигов",
"search_placeholder": "Фильтр по имени, типу или провайдеру",
"search_regex_placeholder": "Сопоставление имени, типа или провайдера по regex",
"search_regex_invalid": "Введите корректный regex-шаблон (не более {{max}} символов)",
"search_regex_unsafe": "Этот regex может вызвать зависание страницы и был заблокирован (избегайте вложенных квантификаторов, альтернативы в повторяющихся группах или обратных ссылок)",
"search_placeholder": "Фильтр по имени, типу или провайдеру, поддерживается wildcard *",
"problem_filter_label": "Фильтр проблем",
"problem_filter_only": "Показывать только проблемные учётные данные",
"display_options_label": "Параметры отображения",
"compact_mode_label": "Компактный режим",
"regex_search_mode_label": "Режим regex",
"sort_label": "Сортировка",
"sort_default": "По умолчанию",
"sort_az": "A-Z Имя",
@@ -1352,7 +1348,8 @@
"description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.",
"refresh_files": "Обновить файлы авторизации",
"refresh_files_and_quota": "Обновить файлы и квоты",
"card_idle_hint": "Используйте кнопку «Обновить файлы и квоты» сверху, чтобы загрузить актуальные данные по квотам."
"refresh_all_credentials": "Обновить все учётные данные",
"card_idle_hint": "Используйте кнопку «Обновить все учётные данные» сверху, чтобы загрузить актуальные данные по квотам."
},
"system_info": {
"title": "Информация о центре управления",
+3 -6
View File
@@ -497,15 +497,11 @@
"pagination_next": "下一页",
"pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件",
"search_label": "搜索配置文件",
"search_placeholder": "输入名称、类型或提供方关键字",
"search_regex_placeholder": "输入正则表达式匹配名称、类型或提供方",
"search_regex_invalid": "请输入有效的正则表达式(最多 {{max}} 个字符)",
"search_regex_unsafe": "该正则表达式可能导致页面卡顿,已阻止执行(避免嵌套量词、重复分组中的 | 或反向引用)",
"search_placeholder": "输入名称、类型或提供方关键字,支持 * 通配",
"problem_filter_label": "问题筛选",
"problem_filter_only": "仅显示有问题凭证",
"display_options_label": "显示选项",
"compact_mode_label": "简略模式",
"regex_search_mode_label": "正则模式",
"sort_label": "排序",
"sort_default": "默认",
"sort_az": "A-Z 名称",
@@ -1347,7 +1343,8 @@
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度",
"card_idle_hint": "请使用顶部“刷新认证文件&额度”按钮获取最新额度。"
"refresh_all_credentials": "刷新全部凭证",
"card_idle_hint": "请使用顶部“刷新全部凭证”按钮获取最新额度。"
},
"system_info": {
"title": "管理中心信息",
+1 -1
View File
@@ -541,7 +541,7 @@
}
.quotaBarFillMedium {
background-color: var(--warning-color);
background-color: var(--quota-medium-color, #e0aa14);
}
.quotaBarFillLow {
+22 -79
View File
@@ -24,7 +24,6 @@ import { IconFilterAll } from '@/components/ui/icons';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { copyToClipboard } from '@/utils/clipboard';
import { isLikelyUnsafeJsRegex } from '@/utils/regexSafety';
import {
MAX_CARD_PAGE_SIZE,
MIN_CARD_PAGE_SIZE,
@@ -68,7 +67,15 @@ const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
const DEFAULT_REGULAR_PAGE_SIZE = 9;
const DEFAULT_COMPACT_PAGE_SIZE = 12;
const MAX_REGEX_SEARCH_PATTERN_LENGTH = 120;
const escapeWildcardSearchSegment = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildWildcardSearch = (value: string): RegExp | null => {
if (!value.includes('*')) return null;
const pattern = value.split('*').map(escapeWildcardSearchSegment).join('.*');
return new RegExp(pattern, 'i');
};
export function AuthFilesPage() {
const { t } = useTranslation();
@@ -83,7 +90,6 @@ export function AuthFilesPage() {
const [problemOnly, setProblemOnly] = useState(false);
const [compactMode, setCompactMode] = useState(false);
const [search, setSearch] = useState('');
const [regexSearchMode, setRegexSearchMode] = useState(false);
const [page, setPage] = useState(1);
const [pageSizeByMode, setPageSizeByMode] = useState({
regular: DEFAULT_REGULAR_PAGE_SIZE,
@@ -204,9 +210,6 @@ export function AuthFilesPage() {
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.regexSearchMode === 'boolean') {
setRegexSearchMode(persisted.regexSearchMode);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
@@ -242,7 +245,6 @@ export function AuthFilesPage() {
problemOnly,
compactMode,
search,
regexSearchMode,
page,
pageSize,
regularPageSize: pageSizeByMode.regular,
@@ -257,7 +259,6 @@ export function AuthFilesPage() {
pageSize,
pageSizeByMode,
problemOnly,
regexSearchMode,
search,
sortMode,
uiStateHydrated,
@@ -377,62 +378,24 @@ export function AuthFilesPage() {
}, [filesMatchingProblemFilter]);
const normalizedSearch = search.trim();
const { regexSearch, regexSearchErrorKey } = useMemo(() => {
if (!regexSearchMode || !normalizedSearch) {
return { regexSearch: null as RegExp | null, regexSearchErrorKey: undefined as string | undefined };
}
if (normalizedSearch.length > MAX_REGEX_SEARCH_PATTERN_LENGTH) {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_invalid',
};
}
if (isLikelyUnsafeJsRegex(normalizedSearch)) {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_unsafe',
};
}
try {
return { regexSearch: new RegExp(normalizedSearch, 'i'), regexSearchErrorKey: undefined };
} catch {
return {
regexSearch: null,
regexSearchErrorKey: 'auth_files.search_regex_invalid',
};
}
}, [normalizedSearch, regexSearchMode]);
const searchError = regexSearchErrorKey
? t(regexSearchErrorKey, { max: MAX_REGEX_SEARCH_PATTERN_LENGTH })
: undefined;
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
const filtered = useMemo(() => {
const normalizedTerm = normalizedSearch.toLowerCase();
return filesMatchingProblemFilter.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
const matchSearch = (() => {
if (!normalizedSearch) return true;
if (!regexSearchMode) {
const term = normalizedSearch.toLowerCase();
return (
item.name.toLowerCase().includes(term) ||
(item.type || '').toString().toLowerCase().includes(term) ||
(item.provider || '').toString().toLowerCase().includes(term)
);
}
if (!regexSearch) return false;
return [item.name, item.type, item.provider].some((value) =>
regexSearch.test((value || '').toString())
);
})();
const matchSearch =
!normalizedSearch ||
[item.name, item.type, item.provider].some((value) => {
const content = (value || '').toString();
return wildcardSearch
? wildcardSearch.test(content)
: content.toLowerCase().includes(normalizedTerm);
});
return matchType && matchSearch;
});
}, [filesMatchingProblemFilter, filter, normalizedSearch, regexSearch, regexSearchMode]);
}, [filesMatchingProblemFilter, filter, normalizedSearch, wildcardSearch]);
const sorted = useMemo(() => {
const copy = [...filtered];
@@ -744,12 +707,7 @@ export function AuthFilesPage() {
setSearch(e.target.value);
setPage(1);
}}
placeholder={
regexSearchMode
? t('auth_files.search_regex_placeholder')
: t('auth_files.search_placeholder')
}
error={searchError}
placeholder={t('auth_files.search_placeholder')}
/>
</div>
<div className={styles.filterItem}>
@@ -811,21 +769,6 @@ export function AuthFilesPage() {
}
/>
</div>
<div className={styles.filterToggleCard}>
<ToggleSwitch
checked={regexSearchMode}
onChange={(value) => {
setRegexSearchMode(value);
setPage(1);
}}
ariaLabel={t('auth_files.regex_search_mode_label')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.regex_search_mode_label')}
</span>
}
/>
</div>
</div>
</div>
</div>
+115 -3
View File
@@ -31,14 +31,22 @@
gap: $spacing-sm;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
min-width: 0;
:global(.btn-sm) {
:global(.btn.btn-sm) {
line-height: 16px;
min-width: 0;
}
:global(svg) {
display: block;
}
@include mobile {
width: 100%;
justify-content: stretch;
}
}
.titleWrapper {
@@ -146,9 +154,95 @@
}
.viewModeToggle {
display: flex;
display: inline-flex;
gap: $spacing-xs;
align-items: center;
padding: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
@include mobile {
flex: 1 1 auto;
width: 100%;
}
}
.viewModeButton:global(.btn.btn-sm) {
padding: 8px 14px;
border-radius: 999px;
border-color: transparent;
background: transparent;
color: var(--text-secondary);
box-shadow: none;
&:hover:not(:disabled) {
border-color: transparent;
background: color-mix(in srgb, var(--bg-hover) 72%, transparent);
color: var(--text-primary);
}
> span {
white-space: nowrap;
}
@include mobile {
flex: 1 1 0;
}
}
.viewModeButtonActive:global(.btn.btn-sm) {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast, #fff);
box-shadow: 0 8px 18px -14px rgba(0, 0, 0, 0.45);
&:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--primary-contrast, #fff);
}
}
.refreshAllButton:global(.btn.btn-sm) {
padding-inline: 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary-color) 22%, var(--border-color));
color: var(--text-primary);
> span {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary-color) 16%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary-color) 34%, var(--border-color));
}
@include mobile {
width: 100%;
justify-content: center;
}
}
:global([data-theme='dark']) .viewModeToggle {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:global([data-theme='dark']) .refreshAllButton:global(.btn.btn-sm) {
background: color-mix(in srgb, var(--primary-color) 18%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary-color) 32%, var(--border-color));
}
@include mobile {
.headerActions {
gap: $spacing-xs;
}
}
// 卡片渐变背景 — 基于 TYPE_COLORS light.bg 转 rgba
@@ -243,7 +337,7 @@
}
.quotaBarFillMedium {
background-color: var(--warning-color);
background-color: var(--quota-medium-color, #e0aa14);
}
.quotaBarFillLow {
@@ -283,6 +377,24 @@
padding: $spacing-sm 0;
}
.quotaMessageAction {
width: 100%;
border: none;
background: none;
cursor: pointer;
text-decoration: underline;
&:hover:not(:disabled) {
color: var(--text-primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
text-decoration: none;
}
}
.quotaError {
font-size: 12px;
color: var(--danger-color);
+3
View File
@@ -32,6 +32,7 @@
--primary-contrast: #ffffff;
--success-color: #10b981;
--quota-medium-color: #e0aa14;
--warning-color: #c65746; // 错误/警告色
--error-color: #c65746;
--danger-color: var(--error-color);
@@ -88,6 +89,7 @@
--primary-contrast: #ffffff;
--success-color: #10b981;
--quota-medium-color: #e0aa14;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: var(--error-color);
@@ -145,6 +147,7 @@
--primary-contrast: #ffffff;
--success-color: #10b981;
--quota-medium-color: #ffd862;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: var(--error-color);
-166
View File
@@ -1,166 +0,0 @@
type GroupState = {
hasInnerVariableQuantifier: boolean;
hasAlternation: boolean;
justOpened: boolean;
};
type Quantifier = {
length: number;
min: number;
max: number | null; // null means unbounded
variable: boolean; // can match multiple lengths for the repeated token
};
const OUTER_REPEAT_MAX_SAFE_UPPER_BOUND = 9;
const isDigit = (ch: string | undefined): ch is string => ch !== undefined && ch >= '0' && ch <= '9';
const readBraceQuantifier = (pattern: string, index: number): Quantifier | null => {
if (pattern[index] !== '{') return null;
let i = index + 1;
let minStr = '';
while (isDigit(pattern[i])) {
minStr += pattern[i];
i += 1;
}
if (minStr.length === 0) return null;
const min = Number(minStr);
let max: number | null = min;
if (pattern[i] === ',') {
i += 1;
let maxStr = '';
while (isDigit(pattern[i])) {
maxStr += pattern[i];
i += 1;
}
max = maxStr.length === 0 ? null : Number(maxStr);
}
if (pattern[i] !== '}') return null;
const variable = max === null || max !== min;
return { length: i - index + 1, min, max, variable };
};
const readQuantifier = (pattern: string, index: number): Quantifier | null => {
const ch = pattern[index];
if (ch === '*') return { length: 1, min: 0, max: null, variable: true };
if (ch === '+') return { length: 1, min: 1, max: null, variable: true };
if (ch === '?') return { length: 1, min: 0, max: 1, variable: true };
if (ch !== '{') return null;
return readBraceQuantifier(pattern, index);
};
/**
* Heuristic safety check for user-supplied JS regex patterns.
*
* Goal: prevent patterns that are very likely to cause catastrophic backtracking
* (e.g. `^(a+)+$`) from running on the main thread.
*
* Notes:
* - This is intentionally conservative but tries to avoid blocking common safe patterns.
* - We do not execute the regex here; only scan the pattern string.
*/
export function isLikelyUnsafeJsRegex(pattern: string): boolean {
let inCharClass = false;
const groupStack: GroupState[] = [
{ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: false },
];
const markInnerVariableQuantifier = () => {
for (let i = 0; i < groupStack.length; i += 1) {
groupStack[i].hasInnerVariableQuantifier = true;
}
};
const markAlternation = () => {
for (let i = 0; i < groupStack.length; i += 1) {
groupStack[i].hasAlternation = true;
}
};
const isOuterRepeatRisky = (q: Quantifier): boolean => {
// If it cannot repeat more than once, it's not a "repeat group" in the sense that
// triggers catastrophic backtracking (e.g. `(a+)?`).
const max = q.max ?? Number.POSITIVE_INFINITY;
if (max <= 1) return false;
// Unbounded repetition is the main hazard: `*`, `+`, `{m,}`.
if (q.max === null) return true;
// Large fixed/variable upper bounds also explode combinatorially with an inner variable quantifier.
return q.max > OUTER_REPEAT_MAX_SAFE_UPPER_BOUND;
};
for (let i = 0; i < pattern.length; i += 1) {
const ch = pattern[i];
// Reset "justOpened" once we move past the first token inside the group.
const top = groupStack[groupStack.length - 1];
if (top.justOpened) {
top.justOpened = false;
// `(?...)` group prefixes use `?` immediately after `(` and are not quantifiers.
if (ch === '?') continue;
}
if (ch === '\\') {
const next = pattern[i + 1];
// Backreferences often make backtracking far worse.
if (next && next >= '1' && next <= '9') return true;
// Named backreference: \k<name>
if (next === 'k' && pattern[i + 2] === '<') return true;
// Skip escaped character.
i += 1;
continue;
}
if (inCharClass) {
if (ch === ']') inCharClass = false;
continue;
}
if (ch === '[') {
inCharClass = true;
continue;
}
if (ch === '(') {
groupStack.push({ hasInnerVariableQuantifier: false, hasAlternation: false, justOpened: true });
continue;
}
if (ch === ')') {
const group = groupStack.pop();
if (!group) return true; // unbalanced, treat as unsafe
const q = readQuantifier(pattern, i + 1);
if (
q &&
isOuterRepeatRisky(q) &&
(group.hasInnerVariableQuantifier || group.hasAlternation)
) {
return true;
}
continue;
}
if (ch === '|') {
// Alternation inside a repeated group is frequently a backtracking hotspot.
markAlternation();
continue;
}
const q = readQuantifier(pattern, i);
if (q) {
if (q.variable) markInnerVariableQuantifier();
i += q.length - 1;
continue;
}
}
return false;
}