diff --git a/tools/SubConverter/cf_worker_v2ray_converter.js b/tools/SubConverter/cf_worker_v2ray_converter.js index a83d81c..6e9343b 100644 --- a/tools/SubConverter/cf_worker_v2ray_converter.js +++ b/tools/SubConverter/cf_worker_v2ray_converter.js @@ -181,6 +181,15 @@ async function handleRequest(request) { return jsonResponse({ error: 'KV 未配置' }, 500) } + // 检测是否使用了本服务的短链接作为源 + const selfShortLinkInfo = await checkSelfShortLink(clashUrl, url.origin) + if (selfShortLinkInfo.isSelfLink) { + return jsonResponse({ + error: '不能使用本服务生成的短链接作为订阅源。请使用原始订阅链接,或直接复制现有订阅。', + hint: selfShortLinkInfo.originalUrl ? `原始订阅地址: ${selfShortLinkInfo.originalUrl}` : null + }, 400) + } + // 临时订阅限制有效期 let finalExpiration = expiration if (subscriptionMode === 'temporary') { @@ -256,29 +265,34 @@ async function handleRequest(request) { } } - // 获取单个订阅详情 + // 获取单个订阅详情 (排除特殊路径: export, import, clear-all) if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'GET') { - try { - const subscriptionId = url.pathname.split('/').pop() + const subscriptionId = url.pathname.split('/').pop() + + // 跳过特殊路径,让后面的路由处理 + if (['export', 'import', 'clear-all'].includes(subscriptionId)) { + // 继续到下一个路由 + } else { + try { + if (typeof SUBSCRIPTION_KV === 'undefined') { + return jsonResponse({ error: 'KV 未配置' }, 500) + } - if (typeof SUBSCRIPTION_KV === 'undefined') { - return jsonResponse({ error: 'KV 未配置' }, 500) + const data = await SUBSCRIPTION_KV.get(`sub:${subscriptionId}`) + if (!data) { + return jsonResponse({ error: '订阅不存在' }, 404) + } + + const subscription = JSON.parse(data) + subscription.shortIds = subscription.shortIds || {} + subscription.shortIdsExpiry = subscription.shortIdsExpiry || {} + subscription.isExpired = subscription.expiresAt ? Date.now() > subscription.expiresAt : false + + return jsonResponse({ success: true, subscription }) + + } catch (error) { + return jsonResponse({ error: error.message }, 500) } - - const data = await SUBSCRIPTION_KV.get(`sub:${subscriptionId}`) - if (!data) { - return jsonResponse({ error: '订阅不存在' }, 404) - } - - const subscription = JSON.parse(data) - subscription.shortIds = subscription.shortIds || {} - subscription.shortIdsExpiry = subscription.shortIdsExpiry || {} - subscription.isExpired = subscription.expiresAt ? Date.now() > subscription.expiresAt : false - - return jsonResponse({ success: true, subscription }) - - } catch (error) { - return jsonResponse({ error: error.message }, 500) } } @@ -302,6 +316,28 @@ async function handleRequest(request) { // 更新允许的字段 if (updates.name) subscription.name = updates.name if (updates.outputType) subscription.outputType = updates.outputType + + // 更新订阅模式(长期/临时) + if (updates.subscriptionMode && ['permanent', 'temporary'].includes(updates.subscriptionMode)) { + subscription.subscriptionMode = updates.subscriptionMode + } + + // 更新有效期 + let expirationChanged = false + if (updates.expiration) { + // 临时订阅限制有效期 + let finalExpiration = updates.expiration + if (subscription.subscriptionMode === 'temporary') { + if (!['1d', '3d', '7d'].includes(updates.expiration)) { + finalExpiration = '3d' + } + } + + subscription.expiration = finalExpiration + const expirationTime = parseExpiration(finalExpiration) + subscription.expiresAt = expirationTime ? Date.now() + expirationTime * 1000 : null + expirationChanged = true + } if (updates.clashUrl && updates.clashUrl !== subscription.clashUrl) { // 如果更新了 clashUrl,重新检测类型并更新所有已存在的短链接 @@ -330,6 +366,34 @@ async function handleRequest(request) { subscription.updatedAt = Date.now() + // 如果有效期变更,更新所有短链的过期时间 + if (expirationChanged && subscription.shortIds) { + const expirationTime = parseExpiration(subscription.expiration) + const newExpiryTime = expirationTime ? Date.now() + expirationTime * 1000 : null + + for (const [linkType, shortId] of Object.entries(subscription.shortIds)) { + if (shortId) { + const shortLinkData = { + url: subscription.clashUrl, + type: subscription.subscriptionType || 'clash', + subscriptionId, + outputType: linkType, + subscriptionMode: subscription.subscriptionMode + } + + const putOptions = expirationTime + ? { expirationTtl: expirationTime, metadata: { updatedAt: Date.now() } } + : { metadata: { updatedAt: Date.now(), permanent: true } } + + await SUBSCRIPTION_KV.put(shortId, JSON.stringify(shortLinkData), putOptions) + + // 更新订阅中记录的短链过期时间 + subscription.shortIdsExpiry = subscription.shortIdsExpiry || {} + subscription.shortIdsExpiry[linkType] = newExpiryTime + } + } + } + // 保存更新后的订阅 await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription)) @@ -344,6 +408,288 @@ async function handleRequest(request) { } } + // 导出所有订阅(CSV格式) + if (url.pathname === '/api/subscriptions/export' && request.method === 'GET') { + try { + if (typeof SUBSCRIPTION_KV === 'undefined') { + return jsonResponse({ error: 'KV 未配置' }, 500) + } + + const subscriptionIds = await getSubscriptionList() + const subscriptions = [] + + for (const id of subscriptionIds) { + const data = await SUBSCRIPTION_KV.get(`sub:${id}`) + if (data) { + subscriptions.push(JSON.parse(data)) + } + } + + // 生成 CSV 内容 + const csvHeader = 'name,clashUrl,subscriptionType,subscriptionMode,expiration,createdAt,expiresAt' + const csvRows = subscriptions.map(sub => { + const name = `"${(sub.name || '').replace(/"/g, '""')}"` + const clashUrl = `"${(sub.clashUrl || '').replace(/"/g, '""')}"` + const subscriptionType = sub.subscriptionType || 'unknown' + const subscriptionMode = sub.subscriptionMode || 'permanent' + const expiration = sub.expiration || '7d' + const createdAt = sub.createdAt ? new Date(sub.createdAt).toISOString() : '' + const expiresAt = sub.expiresAt ? new Date(sub.expiresAt).toISOString() : '' + return `${name},${clashUrl},${subscriptionType},${subscriptionMode},${expiration},${createdAt},${expiresAt}` + }) + + const csvContent = [csvHeader, ...csvRows].join('\n') + + return new Response(csvContent, { + headers: { + 'Content-Type': 'text/csv;charset=UTF-8', + 'Content-Disposition': `attachment; filename="subscriptions_${new Date().toISOString().slice(0,10)}.csv"` + } + }) + } catch (error) { + return jsonResponse({ error: error.message }, 500) + } + } + + // 导入订阅 + if (url.pathname === '/api/subscriptions/import' && request.method === 'POST') { + try { + if (typeof SUBSCRIPTION_KV === 'undefined') { + return jsonResponse({ error: 'KV 未配置' }, 500) + } + + const { content, expiration = '7d', subscriptionMode = 'permanent' } = await request.json() + + if (!content || !content.trim()) { + return jsonResponse({ error: '导入内容不能为空' }, 400) + } + + const importedSubscriptions = [] + const errors = [] + const lines = content.trim().split('\n') + + // 检测格式:CSV 或自定义格式 + const firstLine = lines[0].trim() + const isCSV = firstLine.toLowerCase().includes('name,') && firstLine.toLowerCase().includes('clashurl') + + if (isCSV) { + // 跳过标题行,解析 CSV + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue + + try { + // 简单 CSV 解析(支持引号包裹的字段) + const fields = parseCSVLine(line) + if (fields.length >= 2) { + const name = fields[0].trim() + const clashUrl = fields[1].trim() + + if (name && clashUrl && (clashUrl.startsWith('http://') || clashUrl.startsWith('https://'))) { + importedSubscriptions.push({ name, clashUrl }) + } + } + } catch (e) { + errors.push(`行 ${i + 1}: 解析失败`) + } + } + } else { + // 自定义格式:[订阅名称]: [原始链接] 或 [订阅名称]: \n [原始链接] + // 也支持:[订阅名称] \n [原始链接](无冒号) + let currentName = null + let buffer = '' + let lineNumber = 0 + + for (const line of lines) { + lineNumber++ + const trimmedLine = line.trim() + + if (!trimmedLine) { + // 空行,检查是否有待处理的订阅 + if (currentName && buffer) { + const clashUrl = buffer.trim() + if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) { + importedSubscriptions.push({ name: currentName, clashUrl }) + } else { + errors.push(`行 ${lineNumber}: URL 格式无效`) + } + currentName = null + buffer = '' + } + continue + } + + // 检查是否是纯 URL 行 + if (trimmedLine.startsWith('http://') || trimmedLine.startsWith('https://')) { + if (currentName) { + // 有等待中的名称,这个URL作为它的链接 + importedSubscriptions.push({ name: currentName, clashUrl: trimmedLine }) + currentName = null + buffer = '' + } else { + // 没有名称,使用 URL 的一部分作为名称 + try { + const urlObj = new URL(trimmedLine) + const autoName = urlObj.hostname.replace(/^www\./, '') + ' - ' + lineNumber + importedSubscriptions.push({ name: autoName, clashUrl: trimmedLine }) + } catch (e) { + errors.push(`行 ${lineNumber}: URL 解析失败`) + } + } + continue + } + + // 检查是否是 "名称: 链接" 或 "名称:" 格式 + const colonIndex = trimmedLine.indexOf(':') + if (colonIndex > 0) { + // 先保存之前的订阅 + if (currentName && buffer) { + const clashUrl = buffer.trim() + if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) { + importedSubscriptions.push({ name: currentName, clashUrl }) + } + } + + const namePart = trimmedLine.substring(0, colonIndex).trim() + const urlPart = trimmedLine.substring(colonIndex + 1).trim() + + if (urlPart && (urlPart.startsWith('http://') || urlPart.startsWith('https://'))) { + // 名称和链接在同一行 + importedSubscriptions.push({ name: namePart, clashUrl: urlPart }) + currentName = null + buffer = '' + } else { + // 只有名称或冒号后面不是 URL,链接可能在下一行 + currentName = namePart + buffer = urlPart || '' + } + } else { + // 没有冒号,可能是名称行 + if (currentName && buffer) { + // 保存之前的 + const clashUrl = buffer.trim() + if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) { + importedSubscriptions.push({ name: currentName, clashUrl }) + } + } + // 当作新的名称 + currentName = trimmedLine + buffer = '' + } + } + + // 处理最后一个订阅 + if (currentName && buffer) { + const clashUrl = buffer.trim() + if (clashUrl.startsWith('http://') || clashUrl.startsWith('https://')) { + importedSubscriptions.push({ name: currentName, clashUrl }) + } + } + } + + if (importedSubscriptions.length === 0) { + return jsonResponse({ + error: '没有找到有效的订阅数据。请确保格式正确:每行一个 "名称: URL" 或者使用 CSV 格式。', + errors, + hint: '支持的格式: 1) CSV格式(name,clashUrl,...) 2) 名称: URL 3) 名称换行URL', + debug: { + linesCount: lines.length, + firstLine: lines[0]?.substring(0, 50), + isCSV + } + }, 400) + } + + // 获取现有订阅列表用于去重 + const existingSubscriptionIds = await getSubscriptionList() + const existingUrls = new Set() + for (const id of existingSubscriptionIds) { + const data = await SUBSCRIPTION_KV.get(`sub:${id}`) + if (data) { + const sub = JSON.parse(data) + if (sub.clashUrl) { + existingUrls.add(sub.clashUrl.trim()) + } + } + } + + // 创建订阅(带去重) + const created = [] + const duplicates = [] + const selfLinks = [] + for (const { name, clashUrl } of importedSubscriptions) { + // 检查是否重复 + if (existingUrls.has(clashUrl.trim())) { + duplicates.push({ name, clashUrl }) + continue + } + + // 检查是否是自引用短链接 + const selfCheck = await checkSelfShortLink(clashUrl, url.origin) + if (selfCheck.isSelfLink) { + selfLinks.push({ name, clashUrl, originalUrl: selfCheck.originalUrl }) + continue + } + + try { + const subscriptionType = await detectSubscriptionType(clashUrl) + const subscriptionId = generateShortId() + + let finalExpiration = expiration + if (subscriptionMode === 'temporary') { + if (!['1d', '3d', '7d'].includes(expiration)) { + finalExpiration = '3d' + } + } + + const expirationTime = parseExpiration(finalExpiration) + + const subscription = { + id: subscriptionId, + name, + clashUrl, + subscriptionType, + subscriptionMode, + outputType: 'auto', + shortIds: {}, + shortIdsExpiry: {}, + createdAt: Date.now(), + expiresAt: expirationTime ? Date.now() + expirationTime * 1000 : null, + expiration: finalExpiration, + updatedAt: Date.now() + } + + await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription)) + await addToSubscriptionList(subscriptionId) + created.push({ id: subscriptionId, name }) + // 添加到已存在集合,防止本次导入内的重复 + existingUrls.add(clashUrl.trim()) + } catch (e) { + errors.push(`"${name}": ${e.message}`) + } + } + + let message = `成功导入 ${created.length} 个订阅` + if (duplicates.length > 0) { + message += `,跳过 ${duplicates.length} 个重复` + } + if (selfLinks.length > 0) { + message += `,跳过 ${selfLinks.length} 个自引用短链接` + } + + return jsonResponse({ + success: true, + message, + created, + duplicates: duplicates.length > 0 ? duplicates : undefined, + selfLinks: selfLinks.length > 0 ? selfLinks : undefined, + errors: errors.length > 0 ? errors : undefined + }) + } catch (error) { + return jsonResponse({ error: error.message }, 500) + } + } + // 清空所有KV数据 (必须放在删除订阅路由之前,否则会被正则匹配拦截) if (url.pathname === '/api/subscriptions/clear-all' && request.method === 'POST') { try { @@ -666,6 +1012,50 @@ async function handleRequest(request) { // ========== 辅助函数 ========== +// 检测是否使用了本服务的短链接作为源URL +async function checkSelfShortLink(inputUrl, currentOrigin) { + try { + const parsedUrl = new URL(inputUrl) + + // 检查是否是同一个域名 + if (parsedUrl.origin !== currentOrigin) { + return { isSelfLink: false } + } + + // 检查是否是短链接路径 + if (!parsedUrl.pathname.startsWith('/s/')) { + return { isSelfLink: false } + } + + // 提取短链接ID + const shortId = parsedUrl.pathname.substring(3) + + if (!shortId || typeof SUBSCRIPTION_KV === 'undefined') { + return { isSelfLink: false } + } + + // 尝试从KV获取短链接信息 + const shortLinkJson = await SUBSCRIPTION_KV.get(shortId) + + if (!shortLinkJson) { + // 短链接不存在,但URL格式匹配,仍然阻止 + return { isSelfLink: true, originalUrl: null } + } + + const shortLinkData = JSON.parse(shortLinkJson) + + return { + isSelfLink: true, + originalUrl: shortLinkData.url, + subscriptionId: shortLinkData.subscriptionId, + sourceType: shortLinkData.type + } + } catch (e) { + // 解析失败,说明不是有效URL或不是本服务链接 + return { isSelfLink: false } + } +} + // 检测订阅类型 async function detectSubscriptionType(subscriptionUrl) { try { @@ -1320,6 +1710,35 @@ async function removeFromSubscriptionList(subscriptionId) { await SUBSCRIPTION_KV.put('subscriptions:list', JSON.stringify(newList)) } +// 解析 CSV 行(支持引号包裹的字段) +function parseCSVLine(line) { + const result = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const char = line[i] + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + // 转义的引号 + current += '"' + i++ + } else { + inQuotes = !inQuotes + } + } else if (char === ',' && !inQuotes) { + result.push(current) + current = '' + } else { + current += char + } + } + + result.push(current) + return result +} + function getAdminToken() { if (typeof ADMIN_TOKEN === 'undefined') return null return ADMIN_TOKEN || null @@ -2045,6 +2464,65 @@ function getHTML() { color: var(--text-main); } + /* Loading bar */ + .loading-bar { + width: 100%; + height: 6px; + background: var(--bg-body); + border-radius: 3px; + overflow: hidden; + margin: 16px 0; + } + .loading-bar-inner { + height: 100%; + width: 30%; + background: linear-gradient(90deg, var(--primary), #818cf8); + border-radius: 3px; + animation: loadingSlide 1.5s ease-in-out infinite; + } + @keyframes loadingSlide { + 0% { transform: translateX(-100%); } + 50% { transform: translateX(250%); } + 100% { transform: translateX(-100%); } + } + .import-status { + font-size: 13px; + color: var(--text-secondary); + text-align: center; + padding: 8px; + } + .btn-loading { + opacity: 0.6; + pointer-events: none; + } + .overlay-loading { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(4px); + z-index: 200; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: white; + } + .overlay-loading .spinner { + width: 48px; + height: 48px; + border: 4px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + /* Animations */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @@ -2079,6 +2557,13 @@ function getHTML() { ⋮ -
+
@@ -2274,6 +2759,71 @@ function getHTML() {
+ + +