mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(pluginInstall): add multi-step confirmation for third-party plugin installations with security warnings
This commit is contained in:
@@ -73,6 +73,43 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
// ─── Security Banner (third-party plugin risk) ──────────
|
||||
|
||||
.securityBanner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
padding: 12px 14px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 14%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--quota-medium-color, #e0aa14);
|
||||
}
|
||||
}
|
||||
|
||||
.securityBannerText {
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status Bar ─────────────────────────────────────────
|
||||
|
||||
.statusBar {
|
||||
@@ -323,7 +360,8 @@
|
||||
|
||||
.badge,
|
||||
.badgeSuccess,
|
||||
.badgeWarning {
|
||||
.badgeWarning,
|
||||
.badgeUntrusted {
|
||||
display: inline-flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
@@ -352,6 +390,17 @@
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.badgeUntrusted {
|
||||
gap: 4px;
|
||||
background: rgba($warning-color, 0.12);
|
||||
border: 1px solid rgba($warning-color, 0.4);
|
||||
color: var(--danger-color);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
display: -webkit-box;
|
||||
margin: 0;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconGithub,
|
||||
@@ -12,13 +13,15 @@ import {
|
||||
IconRefreshCw,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
} from '@/components/ui/icons';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { pluginStoreApi } from '@/services/api';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { getErrorMessage, isRecord } from '@/utils/helpers';
|
||||
import type { PluginStoreEntry, PluginStoreResponse } from '@/types';
|
||||
import { buildRepositoryURL, resolvePluginAssetURL } from './pluginResources';
|
||||
import { buildRepositoryURL, isOfficialPlugin, resolvePluginAssetURL } from './pluginResources';
|
||||
import { PluginInstallGateModal } from './components/PluginInstallGateModal';
|
||||
import styles from './PluginStorePage.module.scss';
|
||||
|
||||
type StoreStatusFilter = 'all' | 'installed' | 'notInstalled' | 'updates';
|
||||
@@ -75,6 +78,11 @@ export function PluginStorePage() {
|
||||
const [overflowingDescriptionKeys, setOverflowingDescriptionKeys] = useState<string[]>([]);
|
||||
const descriptionRefs = useRef<Record<string, HTMLParagraphElement | null>>({});
|
||||
|
||||
// Multi-step install gauntlet, shown only for non-official (third-party) plugins.
|
||||
const [gateOpen, setGateOpen] = useState(false);
|
||||
const [gateEntry, setGateEntry] = useState<PluginStoreEntry | null>(null);
|
||||
const [gateIsUpdate, setGateIsUpdate] = useState(false);
|
||||
|
||||
const connected = connectionStatus === 'connected';
|
||||
|
||||
const loadStore = useCallback(async () => {
|
||||
@@ -240,13 +248,49 @@ export function PluginStorePage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const runInstall = useCallback(
|
||||
async (entry: PluginStoreEntry, isUpdate: boolean) => {
|
||||
const entryKey = getStoreEntryKey(entry);
|
||||
const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed';
|
||||
setInstallingKey(entryKey);
|
||||
try {
|
||||
const result = await pluginStoreApi.install(entry.id, entry.sourceId || undefined);
|
||||
showNotification(
|
||||
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
|
||||
'success'
|
||||
);
|
||||
if (result.restartRequired) {
|
||||
setRestartRequiredKeys((current) =>
|
||||
current.includes(entryKey) ? current : [...current, entryKey]
|
||||
);
|
||||
showNotification(t('plugin_store.restart_required_notice'), 'warning');
|
||||
}
|
||||
clearConfigCache();
|
||||
await loadStore();
|
||||
} catch (err: unknown) {
|
||||
showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error');
|
||||
throw err;
|
||||
} finally {
|
||||
setInstallingKey('');
|
||||
}
|
||||
},
|
||||
[clearConfigCache, loadStore, showNotification, t]
|
||||
);
|
||||
|
||||
const handleInstall = (entry: PluginStoreEntry) => {
|
||||
const entryKey = getStoreEntryKey(entry);
|
||||
const isUpdate = entry.installed && entry.updateAvailable;
|
||||
|
||||
// Third-party plugins must clear the multi-step confirmation gauntlet first.
|
||||
if (!isOfficialPlugin(entry)) {
|
||||
setGateEntry(entry);
|
||||
setGateIsUpdate(isUpdate);
|
||||
setGateOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Official router-for-me plugins keep the lightweight single-step confirm.
|
||||
const title = getStoreEntryTitle(entry);
|
||||
const target = entry.version ? `${title} v${entry.version}` : title;
|
||||
const failedKey = isUpdate ? 'plugin_store.update_failed' : 'plugin_store.install_failed';
|
||||
|
||||
showConfirmation({
|
||||
title: isUpdate
|
||||
? t('plugin_store.update_confirm_title')
|
||||
@@ -256,38 +300,25 @@ export function PluginStorePage() {
|
||||
: t('plugin_store.install_confirm_message', { target }),
|
||||
confirmText: isUpdate ? t('plugin_store.update') : t('plugin_store.install'),
|
||||
variant: 'primary',
|
||||
onConfirm: async () => {
|
||||
setInstallingKey(entryKey);
|
||||
try {
|
||||
const result = await pluginStoreApi.install(entry.id, entry.sourceId || undefined);
|
||||
showNotification(
|
||||
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
|
||||
'success'
|
||||
);
|
||||
if (result.restartRequired) {
|
||||
setRestartRequiredKeys((current) =>
|
||||
current.includes(entryKey) ? current : [...current, entryKey]
|
||||
);
|
||||
showNotification(t('plugin_store.restart_required_notice'), 'warning');
|
||||
}
|
||||
clearConfigCache();
|
||||
await loadStore();
|
||||
} catch (err: unknown) {
|
||||
showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error');
|
||||
throw err;
|
||||
} finally {
|
||||
setInstallingKey('');
|
||||
}
|
||||
},
|
||||
onConfirm: () => runInstall(entry, isUpdate),
|
||||
});
|
||||
};
|
||||
|
||||
const handleGateConfirm = useCallback(async () => {
|
||||
if (!gateEntry) return;
|
||||
await runInstall(gateEntry, gateIsUpdate);
|
||||
setGateOpen(false);
|
||||
}, [gateEntry, gateIsUpdate, runInstall]);
|
||||
|
||||
const handleGateClose = useCallback(() => setGateOpen(false), []);
|
||||
|
||||
const renderCard = (entry: PluginStoreEntry) => {
|
||||
const entryKey = getStoreEntryKey(entry);
|
||||
const logo = resolvePluginAssetURL(entry.logo, apiBase);
|
||||
const repositoryURL = buildRepositoryURL(entry.repository);
|
||||
const homepageURL = /^https?:\/\//i.test(entry.homepage) ? entry.homepage : '';
|
||||
const isUpdate = entry.installed && entry.updateAvailable;
|
||||
const isOfficial = isOfficialPlugin(entry);
|
||||
const versionText =
|
||||
isUpdate && entry.installedVersion && entry.version
|
||||
? t('plugin_store.version_arrow', { from: entry.installedVersion, to: entry.version })
|
||||
@@ -317,6 +348,12 @@ export function PluginStorePage() {
|
||||
<span className={styles.cardId}>{entry.id}</span>
|
||||
</div>
|
||||
<div className={styles.cardBadges}>
|
||||
{!isOfficial ? (
|
||||
<span className={styles.badgeUntrusted}>
|
||||
<IconAlertTriangle size={11} />
|
||||
{t('plugin_store.badge_untrusted')}
|
||||
</span>
|
||||
) : null}
|
||||
{isUpdate ? (
|
||||
<span className={styles.badgeWarning}>{t('plugin_store.badge_update')}</span>
|
||||
) : entry.installed ? (
|
||||
@@ -449,6 +486,15 @@ export function PluginStorePage() {
|
||||
<p className={styles.description}>{t('plugin_store.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Security Banner ── */}
|
||||
<div className={styles.securityBanner} role="note">
|
||||
<IconShield size={20} />
|
||||
<div className={styles.securityBannerText}>
|
||||
<strong>{t('plugin_store.security_banner_title')}</strong>
|
||||
<p>{t('plugin_store.security_banner_text')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Alerts ── */}
|
||||
{error ? (
|
||||
<div className={styles.errorBox}>
|
||||
@@ -602,6 +648,15 @@ export function PluginStorePage() {
|
||||
) : (
|
||||
<div className={styles.cardGrid}>{visiblePlugins.map((entry) => renderCard(entry))}</div>
|
||||
)}
|
||||
|
||||
<PluginInstallGateModal
|
||||
open={gateOpen}
|
||||
entry={gateEntry}
|
||||
isUpdate={gateIsUpdate}
|
||||
installing={gateEntry ? installingKey === getStoreEntryKey(gateEntry) : false}
|
||||
onClose={handleGateClose}
|
||||
onConfirm={handleGateConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Multi-step install confirmation ("gauntlet") for third-party plugins.
|
||||
|
||||
.gateModal {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// ── Plugin identity card (shown on every step) ──
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: $spacing-sm 0 $spacing-md;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.logoBox {
|
||||
display: inline-flex;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent);
|
||||
color: var(--text-secondary);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.slug {
|
||||
margin: 0;
|
||||
font-family: $font-mono;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.source {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// ── Step 2: caution banner + effects + untrusted alert ──
|
||||
.warningBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-lg;
|
||||
padding: 12px 14px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid color-mix(in srgb, var(--quota-medium-color, #e0aa14) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--quota-medium-color, #e0aa14) 16%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--quota-medium-color, #e0aa14);
|
||||
}
|
||||
}
|
||||
|
||||
.effects {
|
||||
margin: $spacing-md 0 0;
|
||||
padding-left: $spacing-md;
|
||||
border-left: 2px solid var(--border-color);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 7px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.untrustedAlert {
|
||||
margin-top: $spacing-lg;
|
||||
padding: 12px 14px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid rgba($warning-color, 0.35);
|
||||
background: rgba($warning-color, 0.1);
|
||||
}
|
||||
|
||||
.untrustedText {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.originGrid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
margin: 10px 0 0;
|
||||
|
||||
dt {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: type-to-confirm ──
|
||||
.confirmBlock {
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.confirmPrompt {
|
||||
display: block;
|
||||
margin-bottom: $spacing-sm;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.confirmHint {
|
||||
margin: $spacing-sm 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { IconAlertTriangle, IconPlug } from '@/components/ui/icons';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import type { PluginStoreEntry } from '@/types';
|
||||
import {
|
||||
getPluginConfirmToken,
|
||||
getPluginRepositorySlug,
|
||||
resolvePluginAssetURL,
|
||||
} from '../pluginResources';
|
||||
import styles from './PluginInstallGateModal.module.scss';
|
||||
|
||||
interface PluginInstallGateModalProps {
|
||||
open: boolean;
|
||||
entry: PluginStoreEntry | null;
|
||||
isUpdate: boolean;
|
||||
installing: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
function GateLogo({ src }: { src: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
return src && !failed ? (
|
||||
<img src={src} alt="" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<IconPlug size={26} />
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginInstallGateModal({
|
||||
open,
|
||||
entry,
|
||||
isUpdate,
|
||||
installing,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: PluginInstallGateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const [step, setStep] = useState(1);
|
||||
const [typed, setTyped] = useState('');
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
|
||||
// Reset the gauntlet to step 1 on each fresh open. Adjusting state during render
|
||||
// (React's "you might not need an effect" guidance) avoids a setState-in-effect.
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setStep(1);
|
||||
setTyped('');
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
const title = entry.name || entry.id;
|
||||
const repoSlug = getPluginRepositorySlug(entry.repository);
|
||||
const token = getPluginConfirmToken(entry);
|
||||
const logo = resolvePluginAssetURL(entry.logo, apiBase);
|
||||
const sourceText = entry.sourceName || entry.sourceUrl;
|
||||
const tokenMatches = typed.trim() === token;
|
||||
|
||||
const handleClose = () => {
|
||||
if (installing) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleFinalConfirm = async () => {
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch {
|
||||
// The caller surfaces the error via a notification; stay on this step.
|
||||
}
|
||||
};
|
||||
|
||||
const identity = (
|
||||
<div className={styles.identity}>
|
||||
<div className={styles.logoBox} aria-hidden="true">
|
||||
<GateLogo src={logo} />
|
||||
</div>
|
||||
<h3 className={styles.name}>{title}</h3>
|
||||
<p className={styles.slug}>{repoSlug || entry.id}</p>
|
||||
{sourceText ? (
|
||||
<p className={styles.source}>{t('plugin_store.source_name', { source: sourceText })}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
let body: ReactNode;
|
||||
let footer: ReactNode;
|
||||
|
||||
if (step === 1) {
|
||||
body = identity;
|
||||
footer = (
|
||||
<Button variant="secondary" fullWidth onClick={() => setStep(2)}>
|
||||
{t('plugin_store.gate_step1_action')}
|
||||
</Button>
|
||||
);
|
||||
} else if (step === 2) {
|
||||
body = (
|
||||
<>
|
||||
{identity}
|
||||
<div className={styles.warningBanner}>
|
||||
<IconAlertTriangle size={18} />
|
||||
<span>{t('plugin_store.gate_warning')}</span>
|
||||
</div>
|
||||
<ul className={styles.effects}>
|
||||
<li>{t('plugin_store.gate_effect_runs_code')}</li>
|
||||
<li>{t('plugin_store.gate_effect_no_review')}</li>
|
||||
<li>{t('plugin_store.gate_effect_restart')}</li>
|
||||
</ul>
|
||||
<div className={styles.untrustedAlert}>
|
||||
<p className={styles.untrustedText}>{t('plugin_store.gate_untrusted_alert')}</p>
|
||||
<dl className={styles.originGrid}>
|
||||
<dt>{t('plugin_store.gate_repository_label')}</dt>
|
||||
<dd>{repoSlug || entry.repository || '—'}</dd>
|
||||
<dt>{t('plugin_store.gate_source_label')}</dt>
|
||||
<dd>{sourceText || '—'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
footer = (
|
||||
<Button variant="secondary" fullWidth onClick={() => setStep(3)}>
|
||||
{t('plugin_store.gate_step2_action')}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
{identity}
|
||||
<div className={styles.confirmBlock}>
|
||||
<label className={styles.confirmPrompt} htmlFor="plugin-gate-confirm">
|
||||
{t('plugin_store.gate_step3_prompt', { token })}
|
||||
</label>
|
||||
<Input
|
||||
id="plugin-gate-confirm"
|
||||
value={typed}
|
||||
onChange={(event) => setTyped(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={installing}
|
||||
aria-label={t('plugin_store.gate_step3_prompt', { token })}
|
||||
/>
|
||||
<p className={styles.confirmHint}>{t('plugin_store.gate_step3_hint')}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
footer = (
|
||||
<Button
|
||||
variant="danger"
|
||||
fullWidth
|
||||
onClick={handleFinalConfirm}
|
||||
disabled={!tokenMatches || installing}
|
||||
loading={installing}
|
||||
>
|
||||
{t('plugin_store.gate_step3_action')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={t(isUpdate ? 'plugin_store.gate_title_update' : 'plugin_store.gate_title', {
|
||||
name: title,
|
||||
})}
|
||||
closeDisabled={installing}
|
||||
footer={footer}
|
||||
width={520}
|
||||
className={styles.gateModal}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginListEntry, PluginMenu } from '@/types';
|
||||
import type { PluginListEntry, PluginMenu, PluginStoreEntry } from '@/types';
|
||||
import { normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
export const PLUGIN_RESOURCES_REFRESH_EVENT = 'plugin-resources-refresh';
|
||||
@@ -41,6 +41,42 @@ export const buildRepositoryURL = (repository: string) => {
|
||||
return `https://github.com/${trimmed.replace(/^\/+/, '')}`;
|
||||
};
|
||||
|
||||
// The exact, fully-qualified prefix every first-party repository lives under.
|
||||
// Matching the whole URL (not just the extracted owner) prevents look-alike
|
||||
// hosts like "https://github.com.evil.com/router-for-me/..." from being
|
||||
// mistaken for the official org.
|
||||
export const OFFICIAL_PLUGIN_REPO_PREFIX = 'https://github.com/router-for-me/';
|
||||
|
||||
// Normalize an "owner/repo" slug or repository URL to a bare "owner/repo".
|
||||
export const getPluginRepositorySlug = (repository: string): string => {
|
||||
const trimmed = repository.trim();
|
||||
if (!trimmed) return '';
|
||||
const withoutHost = /^https?:\/\/[^/]+\/(.+)$/i.exec(trimmed)?.[1] ?? trimmed;
|
||||
const [owner = '', repo = ''] = withoutHost.replace(/^\/+/, '').split('/');
|
||||
if (!owner) return '';
|
||||
return repo ? `${owner}/${repo.replace(/\.git$/i, '')}` : owner;
|
||||
};
|
||||
|
||||
// A repository is official only when its canonical github.com URL sits exactly
|
||||
// under the router-for-me org prefix. Slugs ("router-for-me/repo") and full URLs
|
||||
// are both normalized first; anything else (other hosts, look-alike domains,
|
||||
// other owners) is untrusted.
|
||||
export const isOfficialRepository = (repository: string): boolean =>
|
||||
buildRepositoryURL(repository)
|
||||
.toLowerCase()
|
||||
.startsWith(OFFICIAL_PLUGIN_REPO_PREFIX);
|
||||
|
||||
// A plugin is official iff its code repository sits under the router-for-me org.
|
||||
// Every first-party plugin lives there, so the repository URL is the single
|
||||
// source of truth — see isOfficialRepository for the exact match.
|
||||
export const isOfficialPlugin = (entry: PluginStoreEntry): boolean =>
|
||||
isOfficialRepository(entry.repository);
|
||||
|
||||
// The string a user must retype to confirm a risky install: the repo slug when
|
||||
// available (most faithful to the source), otherwise the plugin id.
|
||||
export const getPluginConfirmToken = (entry: PluginStoreEntry): string =>
|
||||
getPluginRepositorySlug(entry.repository) || entry.id;
|
||||
|
||||
export const collectPluginResourceEntries = (
|
||||
plugins: PluginListEntry[]
|
||||
): PluginResourceEntry[] =>
|
||||
|
||||
@@ -1215,7 +1215,24 @@
|
||||
"no_plugins_desc": "The plugin registry did not return any plugins.",
|
||||
"no_matches": "No matching plugins",
|
||||
"no_matches_desc": "No plugins match the current search or filter.",
|
||||
"clear_filters": "Clear filters"
|
||||
"clear_filters": "Clear filters",
|
||||
"security_banner_title": "Third-party plugins run with full backend access",
|
||||
"security_banner_text": "Plugins execute code inside the proxy service and can read your credentials and traffic. Only install plugins you trust — be especially careful with any plugin not published by the official router-for-me organization.",
|
||||
"badge_untrusted": "Third-party",
|
||||
"gate_title": "Install {{name}}",
|
||||
"gate_title_update": "Update {{name}}",
|
||||
"gate_warning": "Unexpected and harmful things can happen if you don't read this.",
|
||||
"gate_effect_runs_code": "This plugin runs untrusted code inside your proxy backend, with access to credentials, requests, and responses.",
|
||||
"gate_effect_no_review": "It is not published or reviewed by the official router-for-me organization and may behave differently in future versions.",
|
||||
"gate_effect_restart": "Installing it changes the local plugin directory and may require a service restart to take effect.",
|
||||
"gate_untrusted_alert": "This plugin is not from the official router-for-me organization. Only continue if you fully trust its author and source.",
|
||||
"gate_repository_label": "Repository",
|
||||
"gate_source_label": "Source",
|
||||
"gate_step1_action": "I want to install this plugin",
|
||||
"gate_step2_action": "I have read and understand these risks",
|
||||
"gate_step3_prompt": "To confirm, type \"{{token}}\" in the box below",
|
||||
"gate_step3_action": "Install this plugin",
|
||||
"gate_step3_hint": "Enter the exact identifier shown above to enable installation."
|
||||
},
|
||||
"plugin_resource": {
|
||||
"title": "Plugin Page",
|
||||
|
||||
@@ -1202,7 +1202,24 @@
|
||||
"no_plugins_desc": "Реестр плагинов не вернул ни одного плагина.",
|
||||
"no_matches": "Нет подходящих плагинов",
|
||||
"no_matches_desc": "Нет плагинов, соответствующих текущему поиску или фильтру.",
|
||||
"clear_filters": "Сбросить фильтры"
|
||||
"clear_filters": "Сбросить фильтры",
|
||||
"security_banner_title": "Сторонние плагины работают с полным доступом к бэкенду",
|
||||
"security_banner_text": "Плагины выполняют код внутри прокси-сервиса и могут читать ваши учётные данные и трафик. Устанавливайте только плагины, которым доверяете, — особенно осторожно относитесь к плагинам, не опубликованным официальной организацией router-for-me.",
|
||||
"badge_untrusted": "Сторонний",
|
||||
"gate_title": "Установка {{name}}",
|
||||
"gate_title_update": "Обновление {{name}}",
|
||||
"gate_warning": "Если вы не прочитаете это, могут произойти неожиданные и опасные последствия.",
|
||||
"gate_effect_runs_code": "Этот плагин выполняет недоверенный код внутри вашего прокси-бэкенда с доступом к учётным данным, запросам и ответам.",
|
||||
"gate_effect_no_review": "Он не опубликован и не проверен официальной организацией router-for-me и может вести себя иначе в будущих версиях.",
|
||||
"gate_effect_restart": "Установка изменяет локальный каталог плагинов и может потребовать перезапуска сервиса.",
|
||||
"gate_untrusted_alert": "Этот плагин не от официальной организации router-for-me. Продолжайте, только если полностью доверяете его автору и источнику.",
|
||||
"gate_repository_label": "Репозиторий",
|
||||
"gate_source_label": "Источник",
|
||||
"gate_step1_action": "Я хочу установить этот плагин",
|
||||
"gate_step2_action": "Я прочитал и понимаю эти риски",
|
||||
"gate_step3_prompt": "Для подтверждения введите «{{token}}» в поле ниже",
|
||||
"gate_step3_action": "Установить этот плагин",
|
||||
"gate_step3_hint": "Введите точный идентификатор, показанный выше, чтобы включить установку."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Информация о центре управления",
|
||||
|
||||
@@ -1215,7 +1215,24 @@
|
||||
"no_plugins_desc": "插件注册表未返回任何插件。",
|
||||
"no_matches": "没有匹配的插件",
|
||||
"no_matches_desc": "当前搜索或筛选条件下没有插件。",
|
||||
"clear_filters": "清除筛选"
|
||||
"clear_filters": "清除筛选",
|
||||
"security_banner_title": "第三方插件以后端完整权限运行",
|
||||
"security_banner_text": "插件会在代理服务内部执行代码,可读取你的凭据与流量。请仅安装你信任的插件——尤其要警惕任何并非由官方组织 router-for-me 发布的插件。",
|
||||
"badge_untrusted": "第三方",
|
||||
"gate_title": "安装 {{name}}",
|
||||
"gate_title_update": "更新 {{name}}",
|
||||
"gate_warning": "如果你不阅读以下内容,可能会发生意料之外的危险后果。",
|
||||
"gate_effect_runs_code": "该插件会在你的代理后端内运行不受信任的代码,并可访问凭据、请求与响应。",
|
||||
"gate_effect_no_review": "它并非由官方组织 router-for-me 发布或审核,且在后续版本中行为可能发生变化。",
|
||||
"gate_effect_restart": "安装会修改本地插件目录,并可能需要重启服务才能生效。",
|
||||
"gate_untrusted_alert": "该插件并非来自官方组织 router-for-me。请仅在你完全信任其作者与来源时继续。",
|
||||
"gate_repository_label": "仓库",
|
||||
"gate_source_label": "来源",
|
||||
"gate_step1_action": "我要安装此插件",
|
||||
"gate_step2_action": "我已阅读并理解上述风险",
|
||||
"gate_step3_prompt": "如需确认,请在下方输入框中键入 “{{token}}”",
|
||||
"gate_step3_action": "安装此插件",
|
||||
"gate_step3_hint": "请输入上方显示的完整标识以启用安装。"
|
||||
},
|
||||
"plugin_resource": {
|
||||
"title": "插件页面",
|
||||
|
||||
@@ -1241,7 +1241,24 @@
|
||||
"no_plugins_desc": "插件註冊表未回傳任何插件。",
|
||||
"no_matches": "沒有符合的插件",
|
||||
"no_matches_desc": "目前搜尋或篩選條件下沒有插件。",
|
||||
"clear_filters": "清除篩選"
|
||||
"clear_filters": "清除篩選",
|
||||
"security_banner_title": "第三方插件以後端完整權限執行",
|
||||
"security_banner_text": "插件會在代理服務內部執行程式碼,可讀取你的憑證與流量。請僅安裝你信任的插件——尤其要警惕任何並非由官方組織 router-for-me 發布的插件。",
|
||||
"badge_untrusted": "第三方",
|
||||
"gate_title": "安裝 {{name}}",
|
||||
"gate_title_update": "更新 {{name}}",
|
||||
"gate_warning": "如果你不閱讀以下內容,可能會發生意料之外的危險後果。",
|
||||
"gate_effect_runs_code": "該插件會在你的代理後端內執行不受信任的程式碼,並可存取憑證、請求與回應。",
|
||||
"gate_effect_no_review": "它並非由官方組織 router-for-me 發布或審核,且在後續版本中行為可能發生變化。",
|
||||
"gate_effect_restart": "安裝會修改本機插件目錄,並可能需要重新啟動服務才能生效。",
|
||||
"gate_untrusted_alert": "該插件並非來自官方組織 router-for-me。請僅在你完全信任其作者與來源時繼續。",
|
||||
"gate_repository_label": "儲存庫",
|
||||
"gate_source_label": "來源",
|
||||
"gate_step1_action": "我要安裝此插件",
|
||||
"gate_step2_action": "我已閱讀並理解上述風險",
|
||||
"gate_step3_prompt": "如需確認,請在下方輸入框中鍵入 “{{token}}”",
|
||||
"gate_step3_action": "安裝此插件",
|
||||
"gate_step3_hint": "請輸入上方顯示的完整識別碼以啟用安裝。"
|
||||
},
|
||||
"plugin_resource": {
|
||||
"title": "插件頁面",
|
||||
|
||||
Reference in New Issue
Block a user