Skip to content
184 changes: 183 additions & 1 deletion server/src/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Router } from 'express';
import { getDb } from '../db/connection.js';
import { encrypt, decrypt } from '../services/crypto.js';
import { config } from '../config.js';
import { proxyRequest, ProxyConfig } from '../services/proxyService.js';
import { proxyRequest, ProxyConfig, validateUrl } from '../services/proxyService.js';
import { logger } from '../services/logger.js';

function getProxyConfig(): ProxyConfig | null {
Expand Down Expand Up @@ -550,4 +550,186 @@ router.post('/api/settings/proxy/test', async (req, res) => {
}
});

// --- RPC Download endpoints ---

function getRpcDownloadConfig(): { host: string; port: number; secret: string } | null {
try {
const db = getDb();
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('rpc_download_config') as { value: string } | undefined;
if (!row?.value) return null;
const parsed = JSON.parse(row.value);
if (parsed && parsed.enabled && parsed.host && parsed.port) {
let secret = '';
if (parsed.secret_encrypted) {
secret = decrypt(parsed.secret_encrypted, config.encryptionKey);
}
return { host: parsed.host, port: parsed.port, secret };
}
return null;
} catch {
return null;
}
}

// GET /api/settings/rpc-download
router.get('/api/settings/rpc-download', (_req, res) => {
try {
const db = getDb();
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('rpc_download_config') as { value: string } | undefined;
if (!row?.value) {
res.json({ enabled: false, host: '', port: 6800 });
return;
}
const parsed = JSON.parse(row.value);
if (parsed.secret_encrypted) {
parsed.hasSecret = true;
}
delete parsed.secret_encrypted;
delete parsed.secret;
res.json(parsed);
} catch {
res.json({ enabled: false, host: '', port: 6800 });
}
});

// PUT /api/settings/rpc-download
router.put('/api/settings/rpc-download', (req, res) => {
try {
const db = getDb();
const { enabled, host, port, secret } = req.body;
const secretProvided = 'secret' in req.body;

const configToStore: Record<string, unknown> = { enabled, host, port };
if (secretProvided && secret) {
configToStore.secret_encrypted = encrypt(secret, config.encryptionKey);
} else if (secretProvided && !secret) {
// Explicitly empty secret - clear stored secret
} else {
// Secret field omitted - preserve existing encrypted secret
const existing = db.prepare('SELECT value FROM settings WHERE key = ?').get('rpc_download_config') as { value: string } | undefined;
if (existing?.value) {
try {
const parsed = JSON.parse(existing.value);
if (parsed.secret_encrypted) {
configToStore.secret_encrypted = parsed.secret_encrypted;
}
} catch { /* ignore */ }
}
}

db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run('rpc_download_config', JSON.stringify(configToStore));

res.json({ success: true });
} catch (err) {
logger.errorFromError('rpc.settings', 'Failed to save RPC download config', err);
res.status(500).json({ error: 'Failed to save RPC download config' });
}
});

// POST /api/settings/rpc-download/test
router.post('/api/settings/rpc-download/test', async (req, res) => {
try {
const { host, port, secret } = req.body;
if (!host || !port) {
res.json({ success: false, error: 'Host and port are required' });
return;
}

const rpcUrl = `http://${host}:${port}/jsonrpc`;
const params = secret ? [`token:${secret}`] : [];

const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'test',
method: 'aria2.getVersion',
params,
}),
signal: AbortSignal.timeout(5000),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

const data = await response.json() as Record<string, unknown>;
if (data.error) {
const error = data.error as { message?: string };
res.json({ success: false, error: error.message || 'RPC error' });
return;
}
const result = data.result as Record<string, unknown> | undefined;
res.json({
success: true,
version: result?.version || 'unknown',
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Connection failed';
const isConnRefused = message.includes('ECONNREFUSED') || message.includes('fetch failed');
res.json({
success: false,
error: isConnRefused ? 'RPC service not running' : message,
});
}
});

// POST /api/download/rpc
router.post('/api/download/rpc', async (req, res) => {
try {
const rpcConfig = getRpcDownloadConfig();
if (!rpcConfig) {
res.status(400).json({ success: false, error: 'RPC download not configured or disabled' });
return;
}

const { url, filename } = req.body;
if (!url) {
res.status(400).json({ success: false, error: 'URL is required' });
return;
}

// SSRF protection: validate the download URL
try {
validateUrl(url);
} catch (e) {
res.status(400).json({ success: false, error: e instanceof Error ? e.message : 'Invalid URL' });
return;
}

const rpcUrl = `http://${rpcConfig.host}:${rpcConfig.port}/jsonrpc`;
const params: unknown[] = rpcConfig.secret
? [`token:${rpcConfig.secret}`, [url]]
: [[url]];
if (filename) {
params.push({ out: filename });
}

const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'download',
method: 'aria2.addUri',
params,
}),
signal: AbortSignal.timeout(10000),
});

const data = await response.json() as Record<string, unknown>;
if (data.error) {
const error = data.error as { message?: string };
res.json({ success: false, error: error.message || 'RPC error' });
return;
}
res.json({ success: true, gid: data.result });
} catch (err) {
const message = err instanceof Error ? err.message : 'Connection failed';
const isConnRefused = message.includes('ECONNREFUSED') || message.includes('fetch failed');
res.json({
success: false,
error: isConnRefused ? 'RPC service not running' : message,
});
}
});

export default router;
2 changes: 1 addition & 1 deletion server/src/services/proxyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface ProxyResponse {
const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '169.254.169.254']);
const PRIVATE_IP_PATTERNS = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./];

function validateUrl(rawUrl: string): void {
export function validateUrl(rawUrl: string): void {
const parsed = new URL(rawUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Blocked proxy request: unsupported protocol '${parsed.protocol}'`);
Expand Down
148 changes: 115 additions & 33 deletions src/components/ReleaseCard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React, { memo, useCallback } from 'react';
import { ExternalLink, GitBranch, Calendar, Download, ChevronDown, ChevronUp, BookOpen, ArrowUpRight, FolderOpen, Folder, BellOff, FileArchive, Code2 } from 'lucide-react';
import React, { memo, useCallback, useRef, useState } from 'react';
import { ExternalLink, GitBranch, Calendar, Download, ChevronDown, ChevronUp, BookOpen, ArrowUpRight, FolderOpen, Folder, BellOff, FileArchive, Code2, Loader2, CheckCircle2 } from 'lucide-react';
import { Release } from '../types';
import { formatDistanceToNow } from 'date-fns';
import MarkdownRenderer from './MarkdownRenderer';
import { useAppStore } from '../store/useAppStore';
import { useDialog } from '../hooks/useDialog';
import { sendToRpcDownload } from '../services/rpcDownloadService';

interface DownloadLink {
name: string;
Expand Down Expand Up @@ -51,6 +54,40 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
}) => {
const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]);

// RPC download support — use refs to avoid stale closure in async handler
const { rpcDownloadConfig, backendApiSecret } = useAppStore();
const { toast } = useDialog();
const downloadingRef = useRef<Record<string, boolean>>({});
const downloadedRef = useRef<Record<string, boolean>>({});
const [, forceUpdate] = useState(0);

const handleRpcDownload = useCallback(async (link: DownloadLink) => {
const key = link.url;
if (downloadingRef.current[key] || downloadedRef.current[key]) return;

downloadingRef.current = { ...downloadingRef.current, [key]: true };
forceUpdate(n => n + 1);
try {
const result = await sendToRpcDownload(link.url, link.name, backendApiSecret || undefined);
if (result.success) {
downloadedRef.current = { ...downloadedRef.current, [key]: true };
toast(t('已发送到远程下载器', 'Sent to remote downloader'), 'success');
} else {
toast(
result.error === 'RPC service not running'
? t('远程下载服务未运行,请检查配置', 'Remote download service not running, please check config')
: result.error || t('发送失败', 'Send failed'),
'error'
);
}
} catch {
toast(t('远程下载服务未运行,请检查配置', 'Remote download service not running, please check config'), 'error');
} finally {
downloadingRef.current = { ...downloadingRef.current, [key]: false };
forceUpdate(n => n + 1);
}
}, [backendApiSecret, toast, t]);

// 判断是否有任何内容展开
const isAnyExpanded = isAssetsExpanded || isReleaseNotesExpanded;

Expand Down Expand Up @@ -203,37 +240,82 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
</div>

<div className="bg-gray-50 dark:bg-[#121314] rounded border border-black/[0.06] dark:border-white/[0.04] max-h-72 overflow-y-auto">
{downloadLinks.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-between px-4 py-3 hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 ${
link.isSourceCode ? 'bg-gray-100 dark:bg-white/[0.04]' : ''
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-1.5 min-w-0 flex-1">
{link.isSourceCode ? (
<Code2 className="w-3.5 h-3.5 text-gray-700 dark:text-text-secondary flex-shrink-0" />
) : (
<Download className="w-3.5 h-3.5 text-gray-400 dark:text-text-quaternary flex-shrink-0" />
)}
<span className={`text-sm truncate ${link.isSourceCode ? 'text-gray-700 dark:text-text-secondary font-medium' : 'text-gray-900 dark:text-text-secondary'}`}>
{link.name}
</span>
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-text-tertiary flex-shrink-0">
{link.size > 0 && (
<span>{formatFileSize(link.size)}</span>
)}
{link.downloadCount > 0 && (
<span>{link.downloadCount.toLocaleString()} {t('下载', 'downloads')}</span>
)}
</div>
</a>
))}
{downloadLinks.map((link, index) => {
const isRpcEnabled = rpcDownloadConfig.enabled;
const isDownloading = downloadingRef.current[link.url];
const isDownloaded = downloadedRef.current[link.url];

if (isRpcEnabled) {
return (
<button
key={index}
onClick={(e) => {
e.stopPropagation();
handleRpcDownload(link);
}}
disabled={isDownloading || isDownloaded}
className={`flex items-center justify-between px-4 py-3 w-full text-left hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 disabled:opacity-60 ${
link.isSourceCode ? 'bg-gray-100 dark:bg-white/[0.04]' : ''
}`}
>
<div className="flex items-center space-x-1.5 min-w-0 flex-1">
{isDownloaded ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />
) : isDownloading ? (
<Loader2 className="w-3.5 h-3.5 text-gray-400 animate-spin flex-shrink-0" />
) : link.isSourceCode ? (
<Code2 className="w-3.5 h-3.5 text-gray-700 dark:text-text-secondary flex-shrink-0" />
) : (
<Download className="w-3.5 h-3.5 text-gray-400 dark:text-text-quaternary flex-shrink-0" />
)}
<span className={`text-sm truncate ${link.isSourceCode ? 'text-gray-700 dark:text-text-secondary font-medium' : 'text-gray-900 dark:text-text-secondary'}`}>
{link.name}
</span>
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-text-tertiary flex-shrink-0">
{link.size > 0 && (
<span>{formatFileSize(link.size)}</span>
)}
{link.downloadCount > 0 && (
<span>{link.downloadCount.toLocaleString()} {t('下载', 'downloads')}</span>
)}
</div>
</button>
);
}

return (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-between px-4 py-3 hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 ${
link.isSourceCode ? 'bg-gray-100 dark:bg-white/[0.04]' : ''
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-1.5 min-w-0 flex-1">
{link.isSourceCode ? (
<Code2 className="w-3.5 h-3.5 text-gray-700 dark:text-text-secondary flex-shrink-0" />
) : (
<Download className="w-3.5 h-3.5 text-gray-400 dark:text-text-quaternary flex-shrink-0" />
)}
<span className={`text-sm truncate ${link.isSourceCode ? 'text-gray-700 dark:text-text-secondary font-medium' : 'text-gray-900 dark:text-text-secondary'}`}>
{link.name}
</span>
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-text-tertiary flex-shrink-0">
{link.size > 0 && (
<span>{formatFileSize(link.size)}</span>
)}
{link.downloadCount > 0 && (
<span>{link.downloadCount.toLocaleString()} {t('下载', 'downloads')}</span>
)}
</div>
</a>
);
})}
</div>
</div>
)}
Expand Down
Loading
Loading