Files
clash_subscriptions/tools/SubConverter/cf_worker_v2ray_converter.js
2025-12-21 12:48:26 +08:00

3620 lines
125 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Cloudflare Worker: 订阅管理与转换工具 (Subscription Manager & Converter)
// 部署说明:复制此代码到 Cloudflare Workers 编辑器中
// 版本v4.1 - 智能订阅管理系统,支持 Clash/V2Ray 双向转换
// KV 存储结构说明:
// 1. 短链接键:{shortId} -> JSON {url, type, subscriptionId, outputType} (JSON对象)
// 2. 订阅配置键sub:{subscriptionId} -> subscription object (JSON)
// 3. 订阅索引键subscriptions:list -> array of subscription IDs (JSON)
// 4. 订阅类型permanent (正式) / temporary (临时)
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const url = new URL(request.url)
// 处理根路径 - 返回网页界面
if (url.pathname === '/' && request.method === 'GET') {
return new Response(getHTML(), {
headers: { 'Content-Type': 'text/html;charset=UTF-8' }
})
}
// 处理转换请求
if (url.pathname === '/convert' && request.method === 'POST') {
try {
const { clashUrl } = await request.json()
if (!clashUrl) {
return jsonResponse({ error: '请提供 Clash 订阅链接' }, 400)
}
const v2raySubscription = await convertClashToV2ray(clashUrl)
return new Response(v2raySubscription, {
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
'Content-Disposition': 'attachment; filename="v2ray_subscription.txt"'
}
})
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// 处理直接URL转换用于在线订阅链接
if (url.pathname === '/convert' && request.method === 'GET') {
const clashUrl = url.searchParams.get('url')
if (!clashUrl) {
return jsonResponse({ error: '请提供 url 参数' }, 400)
}
try {
const v2raySubscription = await convertClashToV2ray(clashUrl)
return new Response(v2raySubscription, {
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// 处理生成短链接(如果配置了 KV
if (url.pathname === '/shorten' && request.method === 'POST') {
try {
const { clashUrl } = await request.json()
if (!clashUrl) {
return jsonResponse({ error: '请提供 Clash 订阅链接' }, 400)
}
// 检查是否配置了 KV
if (typeof SUBSCRIPTION_KV === 'undefined') {
// 如果没有配置 KV直接返回长链接
const longUrl = `${url.origin}/convert?url=${encodeURIComponent(clashUrl)}`
return jsonResponse({ url: longUrl, type: 'direct' })
}
// 使用 KV 生成短链接
const shortId = generateShortId()
const expirationTime = 7 * 24 * 60 * 60 // 7天过期
const metadata = {
clashUrl,
createdAt: Date.now()
}
await SUBSCRIPTION_KV.put(shortId, clashUrl, {
expirationTtl: expirationTime,
metadata
})
const shortUrl = `${url.origin}/s/${shortId}`
return jsonResponse({ url: shortUrl, type: 'short', expiresIn: expirationTime })
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// 处理短链接访问
if (url.pathname.startsWith('/s/')) {
const shortId = url.pathname.substring(3)
const requestedType = url.searchParams.get('type') // clash or v2ray
try {
// 检查是否配置了 KV
if (typeof SUBSCRIPTION_KV === 'undefined') {
return new Response('短链接功能未启用', { status: 404 })
}
const shortLinkJson = await SUBSCRIPTION_KV.get(shortId)
if (!shortLinkJson) {
return new Response('链接不存在或已过期', { status: 404 })
}
const shortLinkData = JSON.parse(shortLinkJson)
const sourceUrl = shortLinkData.url
const sourceType = shortLinkData.type || 'clash'
const defaultOutputType = shortLinkData.outputType || 'v2ray'
const targetType = requestedType || defaultOutputType
let result
// 根据源类型和目标类型进行转换
if (sourceType === 'clash' && targetType === 'v2ray') {
result = await convertClashToV2ray(sourceUrl)
} else if (sourceType === 'v2ray' && targetType === 'clash') {
result = await convertV2rayToClash(sourceUrl)
} else if (sourceType === 'clash' && targetType === 'clash') {
// 直接返回 clash 订阅
const response = await fetch(sourceUrl, {
headers: { 'User-Agent': 'ClashForWindows/0.20.39' }
})
result = await response.text()
} else if (sourceType === 'v2ray' && targetType === 'v2ray') {
// 直接返回 v2ray 订阅
const response = await fetch(sourceUrl, {
headers: { 'User-Agent': 'v2rayN/6.0' }
})
result = await response.text()
} else {
// 默认clash to v2ray
result = await convertClashToV2ray(sourceUrl)
}
return new Response(result, {
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
})
} catch (error) {
return new Response(`转换失败: ${error.message}`, { status: 500 })
}
}
// ========== 订阅管理 API ==========
// 创建新订阅
if (url.pathname === '/api/subscriptions' && request.method === 'POST') {
try {
const { name, clashUrl, expiration = '7d', outputType = 'auto', subscriptionMode = 'permanent' } = await request.json()
if (!name || !clashUrl) {
return jsonResponse({ error: '缺少必需参数 name 或 clashUrl' }, 400)
}
// 检查是否配置了 KV
if (typeof SUBSCRIPTION_KV === 'undefined') {
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') {
// 临时订阅只能选择 1天、3天、7天
if (!['1d', '3d', '7d'].includes(expiration)) {
finalExpiration = '3d' // 默认3天
}
}
// 自动检测订阅类型
const subscriptionType = await detectSubscriptionType(clashUrl)
// 生成订阅ID
const subscriptionId = generateShortId()
const expirationTime = parseExpiration(finalExpiration)
// 创建订阅对象(先创建,短链按需生成)
const subscription = {
id: subscriptionId,
name,
clashUrl,
subscriptionType,
subscriptionMode,
outputType,
shortIds: {}, // 改为对象,存储不同类型的短链: {clash: 'xxx', v2ray: 'yyy'}
shortIdsExpiry: {}, // 存储每个短链的过期时间: {clash: timestamp, v2ray: timestamp}
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)
// 短链按需生成,不在这里预先创建
subscription.isExpired = expirationTime ? Date.now() > subscription.expiresAt : false
return jsonResponse({ success: true, subscription })
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// 获取所有订阅列表
if (url.pathname === '/api/subscriptions' && request.method === 'GET') {
try {
const subscriptionIds = await getSubscriptionList()
const subscriptions = []
for (const id of subscriptionIds) {
const data = await SUBSCRIPTION_KV.get(`sub:${id}`)
if (data) {
const sub = JSON.parse(data)
sub.shortIds = sub.shortIds || {}
sub.shortIdsExpiry = sub.shortIdsExpiry || {}
// 检查是否过期
sub.isExpired = sub.expiresAt ? Date.now() > sub.expiresAt : false
subscriptions.push(sub)
}
}
return jsonResponse({ success: true, subscriptions })
} catch (error) {
console.error('获取订阅列表失败:', error)
return jsonResponse({ error: error.message }, 500)
}
}
// 获取单个订阅详情 (排除特殊路径: export, import, clear-all)
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'GET') {
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)
}
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)
}
}
}
// 更新订阅
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'PUT') {
try {
const subscriptionId = url.pathname.split('/').pop()
const updates = await request.json()
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)
// 更新允许的字段
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重新检测类型并更新所有已存在的短链接
subscription.clashUrl = updates.clashUrl
subscription.subscriptionType = await detectSubscriptionType(updates.clashUrl)
// 更新所有已存在的短链接数据
const ttl = subscription.expiresAt ? Math.floor((subscription.expiresAt - Date.now()) / 1000) : null
const putOptions = ttl && ttl > 0
? { expirationTtl: ttl, metadata: { updatedAt: Date.now() } }
: { metadata: { updatedAt: Date.now(), permanent: true } }
subscription.shortIds = subscription.shortIds || {}
for (const [linkType, shortId] of Object.entries(subscription.shortIds)) {
if (shortId) {
const shortLinkData = {
url: updates.clashUrl,
type: subscription.subscriptionType,
subscriptionId,
outputType: linkType
}
await SUBSCRIPTION_KV.put(shortId, JSON.stringify(shortLinkData), putOptions)
}
}
}
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))
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)
}
}
// 导出所有订阅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 {
if (typeof SUBSCRIPTION_KV === 'undefined') {
return jsonResponse({ error: 'KV 未配置' }, 500)
}
// 列出所有 KV 键并逐一删除
let cursor = undefined
let deletedCount = 0
do {
const listResult = await SUBSCRIPTION_KV.list({ limit: 1000, cursor })
for (const key of listResult.keys) {
await SUBSCRIPTION_KV.delete(key.name)
deletedCount++
}
cursor = listResult.list_complete ? undefined : listResult.cursor
} while (cursor)
return jsonResponse({ success: true, message: `已清空所有数据,共删除 ${deletedCount} 条记录` })
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// 删除订阅
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+$/) && request.method === 'DELETE') {
try {
const subscriptionId = url.pathname.split('/').pop()
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)
// 删除所有短链接
if (subscription.shortIds) {
for (const shortId of Object.values(subscription.shortIds)) {
if (shortId) {
await SUBSCRIPTION_KV.delete(shortId)
}
}
}
// 删除订阅
await SUBSCRIPTION_KV.delete(`sub:${subscriptionId}`)
// 从订阅列表中移除
await removeFromSubscriptionList(subscriptionId)
return jsonResponse({ success: true, message: '订阅已删除' })
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// 续期订阅短链
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+\/renew$/) && request.method === 'POST') {
try {
const subscriptionId = url.pathname.split('/')[3]
const { expiration = '7d', outputType, linkType = 'auto' } = await request.json()
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 || {}
// 临时订阅限制有效期
let finalExpiration = expiration
if (subscription.subscriptionMode === 'temporary') {
if (!['1d', '3d', '7d'].includes(expiration)) {
finalExpiration = '3d'
}
}
const expirationTime = parseExpiration(finalExpiration)
const newExpiryTime = expirationTime ? Date.now() + expirationTime * 1000 : null
// 更新输出类型(如果提供)
if (outputType) {
subscription.outputType = outputType
}
// 检查是否已有该类型的短链,如有则续期,没有则创建
let targetShortId = subscription.shortIds[linkType]
if (!targetShortId) {
// 创建新的短链
targetShortId = generateShortId()
subscription.shortIds[linkType] = targetShortId
}
// 更新短链接过期时间
const shortLinkData = {
url: subscription.clashUrl,
type: subscription.subscriptionType || 'clash',
subscriptionId,
outputType: linkType,
subscriptionMode: subscription.subscriptionMode
}
const putOptions = expirationTime
? { expirationTtl: expirationTime, metadata: { renewedAt: Date.now() } }
: { metadata: { renewedAt: Date.now(), permanent: true } }
await SUBSCRIPTION_KV.put(targetShortId, JSON.stringify(shortLinkData), putOptions)
// 保存该短链的过期时间
subscription.shortIdsExpiry[linkType] = newExpiryTime
// 更新订阅过期时间(取所有短链中最晚的)
const allExpiries = Object.values(subscription.shortIdsExpiry).filter(t => t !== null)
subscription.expiresAt = allExpiries.length > 0 ? Math.max(...allExpiries) : null
subscription.expiration = finalExpiration
subscription.updatedAt = Date.now()
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
subscription.isExpired = false
return jsonResponse({
success: true,
subscription,
message: `已续期: ${finalExpiration}`,
shortId: targetShortId,
shortUrl: `${url.origin}/s/${targetShortId}`
})
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// 重新生成短链
if (url.pathname.match(/^\/api\/subscriptions\/[^\/]+\/regenerate$/) && request.method === 'POST') {
try {
const subscriptionId = url.pathname.split('/')[3]
const { expiration = '7d', outputType, linkType = 'auto' } = await request.json()
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 || {}
// 临时订阅限制有效期
let finalExpiration = expiration
if (subscription.subscriptionMode === 'temporary') {
if (!['1d', '3d', '7d'].includes(expiration)) {
finalExpiration = '3d'
}
}
// 删除该类型的旧短链接(如果存在)
const oldShortId = subscription.shortIds[linkType]
if (oldShortId) {
await SUBSCRIPTION_KV.delete(oldShortId)
}
// 生成新短链接
const newShortId = generateShortId()
const expirationTime = parseExpiration(finalExpiration)
const newExpiryTime = expirationTime ? Date.now() + expirationTime * 1000 : null
// 更新输出类型(如果提供)
if (outputType) {
subscription.outputType = outputType
}
const shortLinkData = {
url: subscription.clashUrl,
type: subscription.subscriptionType || 'clash',
subscriptionId,
outputType: linkType,
subscriptionMode: subscription.subscriptionMode
}
const putOptions = expirationTime
? { expirationTtl: expirationTime, metadata: { regeneratedAt: Date.now() } }
: { metadata: { regeneratedAt: Date.now(), permanent: true } }
await SUBSCRIPTION_KV.put(newShortId, JSON.stringify(shortLinkData), putOptions)
// 更新订阅中该类型的短链
subscription.shortIds[linkType] = newShortId
subscription.shortIdsExpiry[linkType] = newExpiryTime
// 更新订阅过期时间(取所有短链中最晚的)
const allExpiries = Object.values(subscription.shortIdsExpiry).filter(t => t !== null)
subscription.expiresAt = allExpiries.length > 0 ? Math.max(...allExpiries) : null
subscription.expiration = finalExpiration
subscription.updatedAt = Date.now()
await SUBSCRIPTION_KV.put(`sub:${subscriptionId}`, JSON.stringify(subscription))
subscription.isExpired = false
return jsonResponse({
success: true,
subscription,
message: '已生成新短链',
shortId: newShortId,
shortUrl: `${url.origin}/s/${newShortId}`
})
} catch (error) {
return jsonResponse({ error: error.message }, 500)
}
}
// ========== 管理页和API ==========
// 管理页(需要 ADMIN_TOKEN
if (url.pathname === '/admin' && request.method === 'GET') {
const adminToken = getAdminToken()
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
return new Response(getAdminHTML(), {
headers: { 'Content-Type': 'text/html;charset=UTF-8' }
})
}
// 列出短链
if (url.pathname === '/admin/list' && request.method === 'GET') {
const adminToken = getAdminToken()
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
if (typeof SUBSCRIPTION_KV === 'undefined') return new Response('KV 未配置', { status: 500 })
const cursor = url.searchParams.get('cursor') || undefined
const listResult = await SUBSCRIPTION_KV.list({ limit: 50, cursor })
const keys = listResult.keys.map(key => ({
id: key.name,
expiration: key.expiration || null,
metadata: key.metadata || null
}))
return jsonResponse({
keys,
cursor: listResult.cursor || null,
list_complete: listResult.list_complete
})
}
// 续期短链
if (url.pathname === '/admin/renew' && request.method === 'POST') {
const adminToken = getAdminToken()
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
if (typeof SUBSCRIPTION_KV === 'undefined') return new Response('KV 未配置', { status: 500 })
const { id, days = 7 } = await request.json()
if (!id) return jsonResponse({ error: '缺少短链 id' }, 400)
const ttlDays = Math.max(1, parseInt(days, 10) || 7)
const expirationTime = ttlDays * 24 * 60 * 60
const { value, metadata } = await SUBSCRIPTION_KV.getWithMetadata(id, { type: 'text' })
if (!value) return jsonResponse({ error: '短链不存在或已过期' }, 404)
const newMetadata = Object.assign({}, metadata || {}, { renewedAt: Date.now() })
await SUBSCRIPTION_KV.put(id, value, {
expirationTtl: expirationTime,
metadata: newMetadata
})
return jsonResponse({ ok: true, expiresIn: expirationTime })
}
// 删除短链
if (url.pathname === '/admin/delete' && request.method === 'POST') {
const adminToken = getAdminToken()
if (!adminToken) return new Response('管理功能未启用', { status: 404 })
const requestToken = request.headers.get('x-admin-token') || url.searchParams.get('token')
if (requestToken !== adminToken) return new Response('未授权', { status: 401 })
if (typeof SUBSCRIPTION_KV === 'undefined') return new Response('KV 未配置', { status: 500 })
const { id } = await request.json()
if (!id) return jsonResponse({ error: '缺少短链 id' }, 400)
await SUBSCRIPTION_KV.delete(id)
return jsonResponse({ ok: true })
}
return new Response('Not Found', { status: 404 })
}
// ========== 辅助函数 ==========
// 检测是否使用了本服务的短链接作为源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 {
const response = await fetch(subscriptionUrl, {
headers: { 'User-Agent': 'ClashForWindows/0.20.39' },
cf: { cacheTtl: 0, cacheEverything: false }
})
if (!response.ok) return 'unknown'
const text = await response.text()
// 尝试 Base64 解码检测 v2ray
try {
const decoded = atob(text.trim())
if (decoded.includes('vmess://') || decoded.includes('ss://') ||
decoded.includes('trojan://') || decoded.includes('vless://')) {
return 'v2ray'
}
} catch (e) {
// 不是 base64继续检测
}
// 检测是否是 Clash YAML 格式
if (text.includes('proxies:') || text.includes('proxy-groups:')) {
return 'clash'
}
// 直接包含协议链接(未编码的 v2ray
if (text.includes('vmess://') || text.includes('ss://') ||
text.includes('trojan://') || text.includes('vless://')) {
return 'v2ray'
}
return 'unknown'
} catch (error) {
console.error('检测订阅类型失败:', error)
return 'unknown'
}
}
// 解析过期时间支持7, 30d, 3m, 1y, unlimited
function parseExpiration(input) {
if (!input || input === 'unlimited' || input === 'never') {
return null // 无限期
}
const str = String(input).trim().toLowerCase()
// 纯数字,默认天
if (/^\d+$/.test(str)) {
return parseInt(str) * 24 * 60 * 60
}
// 带单位
const match = str.match(/^(\d+)([dmy])$/)
if (match) {
const value = parseInt(match[1])
const unit = match[2]
if (unit === 'd') return value * 24 * 60 * 60
if (unit === 'm') return value * 30 * 24 * 60 * 60
if (unit === 'y') return value * 365 * 24 * 60 * 60
}
// 默认 7 天
return 7 * 24 * 60 * 60
}
// V2Ray 转 Clash简化版支持基本协议
async function convertV2rayToClash(v2rayUrl) {
const response = await fetch(v2rayUrl, {
headers: { 'User-Agent': 'v2rayN/6.0' },
cf: { cacheTtl: 0, cacheEverything: false }
})
if (!response.ok) {
throw new Error(`下载订阅失败: ${response.status}`)
}
let text = await response.text()
// 尝试 Base64 解码
try {
text = atob(text.trim())
} catch (e) {
// 已经是解码状态
}
const lines = text.split('\n').filter(l => l.trim())
const proxies = []
for (const line of lines) {
try {
if (line.startsWith('vmess://')) {
proxies.push(parseVmessToClash(line))
} else if (line.startsWith('ss://')) {
proxies.push(parseShadowsocksToClash(line))
} else if (line.startsWith('trojan://')) {
proxies.push(parseTrojanToClash(line))
}
} catch (e) {
console.error('解析节点失败:', e)
}
}
if (proxies.length === 0) {
throw new Error('没有找到有效的节点')
}
// 生成 Clash YAML
const yaml = generateClashYAML(proxies)
return yaml
}
function parseVmessToClash(vmessLink) {
const json = JSON.parse(atob(vmessLink.substring(8)))
return {
name: json.ps || 'VMess',
type: 'vmess',
server: json.add,
port: parseInt(json.port),
uuid: json.id,
alterId: parseInt(json.aid || 0),
cipher: json.scy || 'auto',
network: json.net || 'tcp',
tls: json.tls === 'tls',
'skip-cert-verify': true
}
}
function parseShadowsocksToClash(ssLink) {
const url = new URL(ssLink)
const userinfo = atob(url.username)
const [cipher, password] = userinfo.split(':')
return {
name: decodeURIComponent(url.hash.substring(1)) || 'SS',
type: 'ss',
server: url.hostname,
port: parseInt(url.port),
cipher: cipher,
password: password
}
}
function parseTrojanToClash(trojanLink) {
const url = new URL(trojanLink)
return {
name: decodeURIComponent(url.hash.substring(1)) || 'Trojan',
type: 'trojan',
server: url.hostname,
port: parseInt(url.port),
password: url.username,
'skip-cert-verify': true
}
}
function generateClashYAML(proxies) {
let yaml = 'proxies:\n'
for (const proxy of proxies) {
yaml += ` - name: "${proxy.name}"\n`
yaml += ` type: ${proxy.type}\n`
yaml += ` server: ${proxy.server}\n`
yaml += ` port: ${proxy.port}\n`
if (proxy.type === 'vmess') {
yaml += ` uuid: ${proxy.uuid}\n`
yaml += ` alterId: ${proxy.alterId}\n`
yaml += ` cipher: ${proxy.cipher}\n`
if (proxy.tls) yaml += ` tls: true\n`
} else if (proxy.type === 'ss') {
yaml += ` cipher: ${proxy.cipher}\n`
yaml += ` password: ${proxy.password}\n`
} else if (proxy.type === 'trojan') {
yaml += ` password: ${proxy.password}\n`
yaml += ` skip-cert-verify: true\n`
}
}
return yaml
}
async function convertClashToV2ray(clashUrl) {
// 1. 下载 Clash 订阅配置
const response = await fetch(clashUrl, {
headers: {
'User-Agent': 'ClashForWindows/0.20.39'
},
cf: {
cacheTtl: 0,
cacheEverything: false
}
})
if (!response.ok) {
throw new Error(`下载订阅失败: ${response.status} ${response.statusText}`)
}
const text = await response.text()
// 2. 解析 YAML (简化版解析器)
const config = parseSimpleYAML(text)
if (!config.proxies || !Array.isArray(config.proxies)) {
throw new Error('订阅格式错误: 找不到 proxies 节点')
}
// 3. 转换每个代理节点
const v2rayLinks = []
for (const proxy of config.proxies) {
try {
const link = convertProxyToV2ray(proxy)
if (link) {
v2rayLinks.push(link)
}
} catch (error) {
console.error(`转换节点失败: ${proxy.name}`, error)
// 继续处理其他节点
}
}
if (v2rayLinks.length === 0) {
throw new Error('没有成功转换的节点')
}
// 4. 生成最终订阅 (Base64 编码)
const allLinks = v2rayLinks.join('\n')
return btoa(unescape(encodeURIComponent(allLinks)))
}
function convertProxyToV2ray(proxy) {
const name = encodeURIComponent(proxy.name || 'node')
const sniValue = proxy.sni || proxy.servername || ''
const fingerprintValue = proxy.fingerprint || proxy['client-fingerprint'] || ''
// Shadowsocks
if (proxy.type === 'ss') {
const userInfo = `${proxy.cipher}:${proxy.password}`
const userInfoB64 = btoa(userInfo)
// 处理插件和插件选项
let plugin = ''
if (proxy.plugin) {
plugin = `?plugin=${encodeURIComponent(proxy.plugin)}`
if (proxy['plugin-opts']) {
const opts = Object.entries(proxy['plugin-opts'])
.map(([k, v]) => `${k}=${v}`)
.join(';')
if (opts) {
plugin += encodeURIComponent(';' + opts)
}
}
}
return `ss://${userInfoB64}@${proxy.server}:${proxy.port}${plugin}#${name}`
}
// VMess
if (proxy.type === 'vmess') {
const net = proxy.network || 'tcp'
const vmessData = {
v: '2',
ps: proxy.name || 'node',
add: proxy.server,
port: String(proxy.port),
id: proxy.uuid,
aid: String(proxy.alterId || 0),
scy: proxy.cipher || 'auto',
net: net,
type: 'none', // 伪装类型默认none
host: '',
path: '',
tls: proxy.tls ? 'tls' : '',
sni: sniValue,
alpn: ''
}
if (proxy['skip-cert-verify']) {
vmessData.allowInsecure = true
}
// 处理 WebSocket
if (net === 'ws' && proxy['ws-opts']) {
vmessData.path = proxy['ws-opts'].path || '/'
vmessData.host = proxy['ws-opts'].headers?.Host || sniValue || ''
}
// 处理 HTTP/2
if (net === 'h2' && proxy['h2-opts']) {
vmessData.path = proxy['h2-opts'].path || '/'
vmessData.host = Array.isArray(proxy['h2-opts'].host)
? proxy['h2-opts'].host.join(',')
: (proxy['h2-opts'].host || '')
}
// 处理 gRPC
if (net === 'grpc' && proxy['grpc-opts']) {
vmessData.path = proxy['grpc-opts']['grpc-service-name'] || ''
vmessData.type = 'gun' // gRPC 的 type
}
// 处理 ALPN
if (proxy.alpn) {
vmessData.alpn = Array.isArray(proxy.alpn)
? proxy.alpn.join(',')
: proxy.alpn
}
// 处理 fingerprint (TLS)
if (fingerprintValue) {
vmessData.fp = fingerprintValue
}
const vmessJson = JSON.stringify(vmessData)
const vmessB64 = btoa(unescape(encodeURIComponent(vmessJson)))
return `vmess://${vmessB64}`
}
// Trojan
if (proxy.type === 'trojan') {
const params = new URLSearchParams()
if (sniValue) params.set('sni', sniValue)
if (proxy.alpn) {
const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn
params.set('alpn', alpn)
}
if (proxy['skip-cert-verify']) params.set('allowInsecure', '1')
// WebSocket 传输
if (proxy.network === 'ws' || proxy['ws-opts']) {
params.set('type', 'ws')
if (proxy['ws-opts']?.path) params.set('path', proxy['ws-opts'].path)
if (proxy['ws-opts']?.headers?.Host) params.set('host', proxy['ws-opts'].headers.Host)
}
// gRPC 传输
if (proxy.network === 'grpc' || proxy['grpc-opts']) {
params.set('type', 'grpc')
if (proxy['grpc-opts']?.['grpc-service-name']) {
params.set('serviceName', proxy['grpc-opts']['grpc-service-name'])
}
if (proxy['grpc-opts']?.mode) {
params.set('mode', proxy['grpc-opts'].mode)
}
}
const query = params.toString() ? `?${params.toString()}` : ''
return `trojan://${proxy.password}@${proxy.server}:${proxy.port}${query}#${name}`
}
// VLESS (部分 Clash 版本支持)
if (proxy.type === 'vless') {
const params = new URLSearchParams()
params.set('encryption', proxy.cipher || 'none')
if (proxy.flow) params.set('flow', proxy.flow)
// TLS/Reality 设置
if (proxy.tls) {
params.set('security', 'tls')
if (sniValue) params.set('sni', sniValue)
if (proxy['skip-cert-verify']) params.set('allowInsecure', '1')
if (fingerprintValue) params.set('fp', fingerprintValue)
}
// Reality 协议支持
if (proxy['reality-opts']) {
params.set('security', 'reality')
if (proxy['reality-opts']['server-name'] && !sniValue) {
params.set('sni', proxy['reality-opts']['server-name'])
} else if (sniValue) {
params.set('sni', sniValue)
}
if (proxy['reality-opts']['public-key']) {
params.set('pbk', proxy['reality-opts']['public-key'])
}
if (proxy['reality-opts']['short-id']) {
params.set('sid', proxy['reality-opts']['short-id'])
}
if (proxy['reality-opts']['fingerprint']) {
params.set('fp', proxy['reality-opts']['fingerprint'])
} else if (fingerprintValue) {
params.set('fp', fingerprintValue)
}
if (proxy['reality-opts'].dest) {
params.set('dest', proxy['reality-opts'].dest)
}
if (proxy['reality-opts'].spiderx || proxy['reality-opts'].spiderX) {
params.set('spx', proxy['reality-opts'].spiderx || proxy['reality-opts'].spiderX)
}
}
// 传输协议
const network = proxy.network || 'tcp'
params.set('type', network)
if (network === 'ws' && proxy['ws-opts']) {
if (proxy['ws-opts'].path) params.set('path', proxy['ws-opts'].path)
const wsHost = proxy['ws-opts'].headers?.Host || sniValue
if (wsHost) params.set('host', wsHost)
}
// XHTTP (httpupgrade) 传输
if (network === 'xhttp' && proxy['xhttp-opts']) {
if (proxy['xhttp-opts'].path) params.set('path', proxy['xhttp-opts'].path)
const xhHost = proxy['xhttp-opts'].host || sniValue
if (xhHost) params.set('host', xhHost)
if (proxy['xhttp-opts'].mode) params.set('mode', proxy['xhttp-opts'].mode)
}
if (network === 'grpc' && proxy['grpc-opts']) {
params.set('serviceName', proxy['grpc-opts']['grpc-service-name'] || '')
if (proxy['grpc-opts'].mode) params.set('mode', proxy['grpc-opts'].mode)
}
if (network === 'h2' && proxy['h2-opts']) {
if (proxy['h2-opts'].path) params.set('path', proxy['h2-opts'].path)
if (proxy['h2-opts'].host) {
const host = Array.isArray(proxy['h2-opts'].host)
? proxy['h2-opts'].host.join(',')
: proxy['h2-opts'].host
params.set('host', host)
} else if (sniValue) {
params.set('host', sniValue)
}
}
// ALPN
if (proxy.alpn) {
const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn
params.set('alpn', alpn)
}
return `vless://${proxy.uuid}@${proxy.server}:${proxy.port}?${params.toString()}#${name}`
}
// Hysteria (v1)
if (proxy.type === 'hysteria') {
const params = new URLSearchParams()
if (proxy.protocol) params.set('protocol', proxy.protocol)
if (proxy.up) params.set('upmbps', proxy.up)
if (proxy.down) params.set('downmbps', proxy.down)
if (proxy.obfs) params.set('obfs', proxy.obfs)
if (proxy['obfs-password']) params.set('obfsParam', proxy['obfs-password'])
if (proxy.alpn) {
const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn
params.set('alpn', alpn)
}
if (proxy['skip-cert-verify']) params.set('insecure', '1')
if (sniValue) params.set('peer', sniValue)
const auth = proxy.auth || proxy.password || ''
return `hysteria://${proxy.server}:${proxy.port}?auth=${encodeURIComponent(auth)}&${params.toString()}#${name}`
}
// Hysteria2
if (proxy.type === 'hysteria2' || proxy.type === 'hy2') {
const params = new URLSearchParams()
if (proxy.password) params.set('password', proxy.password)
if (proxy.obfs) {
params.set('obfs', proxy.obfs)
if (proxy['obfs-password']) params.set('obfs-password', proxy['obfs-password'])
}
if (sniValue) params.set('sni', sniValue)
if (proxy['skip-cert-verify']) params.set('insecure', '1')
if (fingerprintValue) params.set('pinSHA256', fingerprintValue)
return `hysteria2://${proxy.password}@${proxy.server}:${proxy.port}?${params.toString()}#${name}`
}
console.log(`不支持的协议类型: ${proxy.type}`)
return null
}
// 增强的 YAML 解析器(支持嵌套对象)
function parseSimpleYAML(yamlText) {
const config = { proxies: [] }
const lines = yamlText.split('\n')
let inProxies = false
let currentProxy = null
let currentNested = null
let nestedKey = null
for (let line of lines) {
const trimmed = line.trim()
if (trimmed === 'proxies:') {
inProxies = true
continue
}
if (!inProxies) continue
// 检测新的代理节点 ( - name: xxx)
if (line.match(/^ - name:/)) {
if (currentProxy) {
config.proxies.push(currentProxy)
}
currentProxy = {}
currentNested = null
nestedKey = null
const match = line.match(/name:\s*(.+)/)
if (match) currentProxy.name = match[1].replace(/['"]/g, '').trim()
continue
}
// 检测嵌套对象开始 ( xxx-opts:) 或数组开始
if (currentProxy && line.match(/^ [\w-]+:/)) {
const match = line.match(/^ ([\w-]+):\s*(.*)$/)
if (match) {
const key = match[1]
let value = match[2].trim()
// 如果值为空,表示这是一个嵌套对象或数组的开始
if (!value || value === '') {
nestedKey = key
// 检查下一行是否是数组项(以 - 开头)
currentNested = {}
currentProxy[key] = currentNested
} else {
// 普通键值对
value = parseValue(value)
currentProxy[key] = value
currentNested = null
nestedKey = null
}
}
continue
}
// 处理数组项 ( - value)
if (currentProxy && line.match(/^ - /)) {
const match = line.match(/^ - (.+)/)
if (match && nestedKey) {
const value = parseValue(match[1])
// 如果还不是数组,转换为数组
if (!Array.isArray(currentProxy[nestedKey])) {
currentProxy[nestedKey] = []
}
currentProxy[nestedKey].push(value)
}
continue
}
// 解析嵌套对象的属性 ( key: value)
if (currentNested && line.match(/^ [\w-]+:/)) {
const match = line.match(/^ ([\w-]+):\s*(.+)/)
if (match) {
const key = match[1]
let value = parseValue(match[2])
// 特殊处理 headers
if ((nestedKey === 'ws-opts' || nestedKey === 'h2-opts') && key === 'headers') {
currentNested.headers = {}
continue
}
currentNested[key] = value
}
continue
}
// 解析更深层嵌套 ( Host: xxx)
if (currentNested && line.match(/^ [\w-]+:/)) {
const match = line.match(/^ ([\w-]+):\s*(.+)/)
if (match) {
const key = match[1]
const value = parseValue(match[2])
if (currentNested.headers) {
currentNested.headers[key] = value
}
}
continue
}
// 检测新的 section退出 proxies
if (inProxies && line.match(/^[\w-]+:/) && !line.match(/^ /)) {
if (currentProxy) {
config.proxies.push(currentProxy)
}
break
}
}
if (currentProxy) {
config.proxies.push(currentProxy)
}
return config
}
// 解析 YAML 值
function parseValue(str) {
// 去掉行内注释(# 之后的内容)
const noComment = str.split('#')[0]
let value = noComment.replace(/['"]/g, '').trim()
// 数字
if (/^\d+$/.test(value)) {
return parseInt(value)
}
// 布尔值
if (value === 'true') return true
if (value === 'false') return false
// 数组 [a, b, c]
if (value.startsWith('[') && value.endsWith(']')) {
return value.slice(1, -1).split(',').map(v => v.trim())
}
return value
}
// 生成短ID
function generateShortId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// 获取订阅列表
async function getSubscriptionList() {
if (typeof SUBSCRIPTION_KV === 'undefined') return []
const data = await SUBSCRIPTION_KV.get('subscriptions:list')
return data ? JSON.parse(data) : []
}
// 添加到订阅列表
async function addToSubscriptionList(subscriptionId) {
if (typeof SUBSCRIPTION_KV === 'undefined') return
const list = await getSubscriptionList()
if (!list.includes(subscriptionId)) {
list.push(subscriptionId)
await SUBSCRIPTION_KV.put('subscriptions:list', JSON.stringify(list))
}
}
// 从订阅列表中移除
async function removeFromSubscriptionList(subscriptionId) {
if (typeof SUBSCRIPTION_KV === 'undefined') return
const list = await getSubscriptionList()
const newList = list.filter(id => id !== 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
}
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' }
})
}
function getHTML() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subscription Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #4f46e5;
--primary-hover: #4338ca;
--primary-light: #eef2ff;
--primary-transparent: rgba(79, 70, 229, 0.1);
--success: #10b981;
--success-light: #d1fae5;
--warning: #f59e0b;
--warning-light: #fef3c7;
--danger: #ef4444;
--danger-light: #fee2e2;
--bg-body: #f8fafc;
--bg-surface: #ffffff;
--bg-sidebar: rgba(255, 255, 255, 0.8);
--text-main: #0f172a;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
--border: #e2e8f0;
--radius-xl: 24px;
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 8px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-float: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; }
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg-body);
color: var(--text-main);
height: 100vh;
display: flex;
padding: 24px;
overflow: hidden;
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.15) 0px, transparent 50%);
}
.app-container {
width: 100%;
max-width: 1400px;
height: 100%;
margin: 0 auto;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-2xl);
border: 1px solid rgba(255, 255, 255, 0.5);
display: flex;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 340px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
z-index: 10;
}
.sidebar-header {
padding: 32px 24px;
}
.app-title {
font-size: 24px;
font-weight: 800;
background: linear-gradient(135deg, var(--primary) 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.app-logo {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--primary), #818cf8);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.app-subtitle {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
padding-left: 44px;
}
.sidebar-actions {
padding: 0 24px 20px;
display: flex;
gap: 12px;
}
.btn-add {
flex: 1;
background: var(--primary);
color: white;
border: none;
padding: 12px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 6px rgba(79, 70, 229, 0.25);
}
.btn-add:hover {
background: var(--primary-hover);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(79, 70, 229, 0.3);
}
.btn-icon {
width: 42px;
height: 42px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: white;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 16px;
}
.btn-icon:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-light);
}
.dropdown-container {
position: relative;
}
.dropdown-menu {
display: none;
position: absolute;
top: 48px;
right: 0;
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 200px;
z-index: 1000;
overflow: hidden;
}
.dropdown-menu.active {
display: block;
animation: fadeIn 0.2s ease;
}
.dropdown-item {
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--text-main);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 10px;
}
.dropdown-item:hover {
background: var(--bg-body);
}
.dropdown-item.danger {
color: var(--danger);
}
.dropdown-item.danger:hover {
background: var(--danger-light);
}
.subscription-list {
flex: 1;
overflow-y: auto;
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sub-item {
padding: 18px;
background: white;
border: 1px solid transparent;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
box-shadow: var(--shadow-sm);
}
.sub-item:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--primary-light);
}
.sub-item.active {
background: var(--bg-surface);
border-color: var(--primary);
box-shadow: var(--shadow-lg);
border-width: 2px;
}
.sub-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.sub-name {
font-weight: 700;
font-size: 15px;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.badge-group {
display: flex;
gap: 6px;
}
.badge {
font-size: 10px;
padding: 4px 8px;
border-radius: 6px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.badge-clash { background: #e0f2fe; color: #0284c7; }
.badge-v2ray { background: #dcfce7; color: #16a34a; }
.badge-temp { background: #fffbeb; color: #d97706; }
.badge-perm { background: #f1f5f9; color: #475569; }
.badge-expired { background: #fee2e2; color: #dc2626; }
.badge-expiring { background: #ffedd5; color: #ea580c; }
.sub-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--border);
margin-top: 4px;
}
.sub-info-text {
font-size: 11px;
color: var(--text-secondary);
font-weight: 500;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
position: relative;
background: rgba(255, 255, 255, 0.5);
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
animation: fadeIn 0.5s ease;
}
.empty-icon {
font-size: 80px;
margin-bottom: 24px;
opacity: 0.8;
filter: drop-shadow(0 10px 10px rgba(0,0,0,0.1));
}
.empty-text {
font-size: 18px;
font-weight: 600;
color: var(--text-secondary);
}
.detail-view {
padding: 48px 64px;
width: 100%;
max-width: 1000px;
margin: 0 auto;
animation: slideUp 0.4s ease;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
}
.detail-title-group h2 {
font-size: 32px;
font-weight: 800;
color: var(--text-main);
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.detail-meta {
display: flex;
gap: 12px;
align-items: center;
}
.action-bar {
display: flex;
gap: 12px;
}
.btn-action {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border);
background: white;
color: var(--text-secondary);
transition: all 0.2s;
box-shadow: var(--shadow-sm);
}
.btn-action:hover {
background: var(--bg-body);
color: var(--text-main);
border-color: #cbd5e1;
transform: translateY(-1px);
}
.btn-danger {
color: var(--danger);
border-color: var(--danger-light);
background: #fffafa;
}
.btn-danger:hover {
background: var(--danger-light);
border-color: #fca5a5;
color: #dc2626;
}
.card {
background: white;
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: var(--radius-lg);
padding: 32px;
margin-bottom: 32px;
box-shadow: var(--shadow-md);
transition: transform 0.3s ease;
}
.card:hover {
box-shadow: var(--shadow-lg);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.card-title {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
display: flex;
align-items: center;
gap: 10px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 32px;
}
.info-item label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.info-item div {
font-size: 15px;
font-weight: 600;
color: var(--text-main);
}
.full-width { grid-column: 1 / -1; }
.url-box {
background: var(--bg-body);
padding: 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
color: var(--text-secondary);
font-family: 'Menlo', 'Monaco', monospace;
font-size: 13px;
word-break: break-all;
line-height: 1.5;
}
/* Tabs */
.tab-container {
display: flex;
background: var(--bg-body);
padding: 4px;
border-radius: 14px;
width: fit-content;
}
.tab-btn {
padding: 10px 24px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: white;
color: var(--primary);
box-shadow: var(--shadow-sm);
}
/* URL Generator Area */
.generator-box {
margin-top: 24px;
position: relative;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.url-input {
width: 100%;
padding: 18px 120px 18px 20px;
background: var(--bg-body);
border: 2px solid transparent;
border-radius: var(--radius-md);
font-family: 'Monaco', monospace;
font-size: 14px;
color: var(--text-main);
outline: none;
transition: all 0.2s;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
}
.url-input:focus {
background: white;
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--primary-light);
}
.btn-copy-absolute {
position: absolute;
right: 8px;
background: white;
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-main);
cursor: pointer;
transition: all 0.2s;
box-shadow: var(--shadow-sm);
}
.btn-copy-absolute:hover {
color: var(--primary);
border-color: var(--primary);
transform: translateY(-1px);
}
.link-status-bar {
margin-top: 20px;
display: flex;
align-items: center;
gap: 16px;
background: var(--bg-body);
padding: 12px 16px;
border-radius: var(--radius-md);
}
.progress-track {
flex: 1;
height: 10px;
background: #e2e8f0;
border-radius: 5px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--success);
border-radius: 5px;
width: 0%;
transition: width 1s ease, background-color 0.3s;
}
.status-text {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
min-width: 100px;
text-align: right;
}
.action-row {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px dashed var(--border);
}
/* Modals */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(8px);
z-index: 100;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.modal.active { display: flex; opacity: 1; }
.modal-content {
background: white;
border-radius: var(--radius-xl);
padding: 40px;
width: 90%;
max-width: 520px;
box-shadow: var(--shadow-float);
transform: scale(0.95);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal.active .modal-content { transform: scale(1); }
.modal-header {
font-size: 24px;
font-weight: 800;
color: var(--text-main);
margin-bottom: 32px;
text-align: center;
}
.form-group { margin-bottom: 24px; }
.form-label {
display: block;
font-weight: 600;
font-size: 14px;
color: var(--text-main);
margin-bottom: 10px;
}
.form-input, .form-select {
width: 100%;
padding: 14px 16px;
border: 2px solid var(--border);
border-radius: var(--radius-md);
font-size: 15px;
outline: none;
transition: all 0.2s;
background: var(--bg-body);
}
.form-input:focus, .form-select:focus {
border-color: var(--primary);
background: white;
box-shadow: 0 0 0 4px var(--primary-light);
}
.form-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 8px;
padding-left: 4px;
}
.modal-footer {
display: flex;
gap: 16px;
margin-top: 40px;
}
.btn-primary {
flex: 2;
background: var(--primary);
color: white;
border: none;
padding: 16px;
border-radius: var(--radius-lg);
font-weight: 700;
font-size: 15px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.4);
}
.btn-secondary {
flex: 1;
background: white;
color: var(--text-secondary);
border: 2px solid var(--border);
padding: 16px;
border-radius: var(--radius-lg);
font-weight: 700;
font-size: 15px;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
border-color: #cbd5e1;
background: var(--bg-body);
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); } }
/* Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body>
<!-- (HTML structure is same, omitted to save token usage perception, but technically tool output is not token heavy) -->
<!-- I will re-output the whole thing to be safe -->
<div class="app-container">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<div class="app-title">
<div class="app-logo">⚡️</div>
<span>SubManager</span>
</div>
<div class="app-subtitle">Cloudflare Subscription Tool</div>
</div>
<div class="sidebar-actions">
<button class="btn-add" onclick="showAddModal()">
<span></span> New Subscription
</button>
<div class="dropdown-container">
<button class="btn-icon" onclick="toggleDropdown()" title="More Options">
</button>
<div id="dropdownMenu" class="dropdown-menu">
<div class="dropdown-item" onclick="showImportModal()">
📥 Import Subscriptions
</div>
<div class="dropdown-item" onclick="exportSubscriptions()">
📤 Export Subscriptions
</div>
<div style="height: 1px; background: var(--border); margin: 8px 0;"></div>
<div class="dropdown-item danger" onclick="clearAllSubscriptions()">
🗑️ Clear All Data
</div>
</div>
</div>
</div>
<div id="subscriptionList" class="subscription-list">
<!-- List items injected by JS -->
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<div id="emptyState" class="empty-state">
<div class="empty-icon">👋</div>
<div class="empty-text">Select a subscription to manage</div>
</div>
<div id="detailView" class="detail-view" style="display: none;">
<div class="detail-header">
<div class="detail-title-group">
<h2 id="detailTitle">Subscription Name</h2>
<div class="detail-meta">
<span id="detailTypeBadge" class="badge"></span>
<span id="detailModeBadge" class="badge"></span>
</div>
</div>
<div class="action-bar">
<button class="btn-action" onclick="editSubscription()">✏️ Edit</button>
<button class="btn-action btn-danger" onclick="deleteCurrentSubscription()">🗑️ Delete</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">📝 Basic Information</div>
</div>
<div class="info-grid">
<div class="info-item">
<label>Source Type</label>
<div id="detailSourceType">-</div>
</div>
<div class="info-item">
<label>Created At</label>
<div id="detailCreatedAt">-</div>
</div>
<div class="info-item">
<label>Expires At</label>
<div id="detailExpiration">-</div>
</div>
<div class="info-item full-width">
<label>Source URL</label>
<div id="detailOriginalUrl" class="url-box">-</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">🔗 Converter Link</div>
<div class="tab-container">
<button class="tab-btn active" data-type="clash" onclick="switchLinkType('clash')">Clash</button>
<button class="tab-btn" data-type="v2ray" onclick="switchLinkType('v2ray')">V2Ray</button>
</div>
</div>
<div class="generator-box">
<div class="input-wrapper">
<input type="text" id="detailShortUrl" class="url-input" readonly placeholder="Generating...">
<button class="btn-copy-absolute" onclick="copyShortUrl(event)">Copy Link</button>
</div>
<div class="link-status-bar">
<span style="font-size:12px; color:var(--text-tertiary);">VALIDITY</span>
<div class="progress-track">
<div id="linkProgressBar" class="progress-fill"></div>
</div>
<div id="linkProgressText" class="status-text">--</div>
</div>
<div id="linkActionRow" class="action-row">
<button class="btn-action" style="color: var(--primary); border-color: var(--primary-light); width: 100%; justify-content: center;" onclick="renewSubscription()">🔄 Renew Validity</button>
<button class="btn-action" style="width: 100%; justify-content: center;" onclick="regenerateShortUrl()">⚡️ Regenerate Token</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="subscriptionModal" class="modal">
<div class="modal-content">
<div class="modal-header" id="modalTitle">New Subscription</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" id="subName" class="form-input" placeholder="e.g. My Premium Node">
</div>
<div class="form-group">
<label class="form-label">Source URL</label>
<input type="url" id="subClashUrl" class="form-input" placeholder="https://...">
<div class="form-hint">Supports Clash or V2Ray/SS/VMess links</div>
</div>
<div class="form-group">
<label class="form-label">Mode</label>
<select id="subMode" class="form-select" onchange="onSubscriptionModeChange()">
<option value="permanent">Permanent (Long-term)</option>
<option value="temporary">Temporary (Short-term)</option>
</select>
</div>
<div id="expirationGroup" class="form-group">
<label class="form-label">Short Link Expiration</label>
<div id="permanentExpiration">
<select id="subPermanentExpiration" class="form-select">
<option value="7d">7 Days</option>
<option value="1m">1 Month</option>
<option value="3m">3 Months</option>
<option value="1y" selected>1 Year</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<div id="temporaryExpiration" style="display: none;">
<select id="subTempExpiration" class="form-select">
<option value="1d">1 Day</option>
<option value="3d" selected>3 Days</option>
<option value="7d">7 Days</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn-primary" onclick="saveSubscription()">Save Subscription</button>
</div>
</div>
</div>
<!-- Renew Modal -->
<div id="renewModal" class="modal">
<div class="modal-content">
<div class="modal-header" id="renewModalTitle">Renew Link</div>
<div class="form-group">
<label class="form-label">New Duration</label>
<div id="renewPermanentExpiration">
<select id="renewPermanentExp" class="form-select">
<option value="7d">7 Days</option>
<option value="1m">1 Month</option>
<option value="3m">3 Months</option>
<option value="1y" selected>1 Year</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<div id="renewTemporaryExpiration" style="display: none;">
<select id="renewTempExpiration" class="form-select">
<option value="1d">1 Day</option>
<option value="3d" selected>3 Days</option>
<option value="7d">7 Days</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeRenewModal()">Cancel</button>
<button class="btn-primary" onclick="confirmRenew()">Confirm</button>
</div>
</div>
</div>
<!-- Alert Modal -->
<div id="customAlertModal" class="modal">
<div class="modal-content" style="max-width: 400px; text-align: center;">
<div class="modal-header" id="customAlertTitle" style="font-size: 20px; margin-bottom: 16px;">Notice</div>
<div id="customAlertMessage" style="color: var(--text-secondary); margin-bottom: 32px; line-height: 1.6;"></div>
<button class="btn-primary" style="width: 100%;" onclick="closeCustomAlert()">OK</button>
</div>
</div>
<!-- Confirm Modal -->
<div id="customConfirmModal" class="modal">
<div class="modal-content" style="max-width: 400px; text-align: center;">
<div class="modal-header" id="customConfirmTitle" style="font-size: 20px; margin-bottom: 16px;">Confirm</div>
<div id="customConfirmMessage" style="color: var(--text-secondary); margin-bottom: 32px; line-height: 1.6;"></div>
<div class="modal-footer" style="margin-top: 24px;">
<button class="btn-secondary" onclick="closeCustomConfirm()">Cancel</button>
<button class="btn-primary" onclick="executeCustomConfirm()">Confirm</button>
</div>
</div>
</div>
<!-- Import Modal -->
<div id="importModal" class="modal">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">Import Subscriptions</div>
<div class="form-group">
<label class="form-label">Import Content</label>
<textarea id="importContent" class="form-input" style="height: 200px; resize: vertical; font-family: monospace; font-size: 13px;" placeholder="Supported formats:
1. CSV format (with header):
name,clashUrl,...
My Sub,https://example.com/sub
2. Custom format:
Subscription Name: https://example.com/sub
or:
Subscription Name:
https://example.com/sub
(Blank lines separate different subscriptions)"></textarea>
<div class="form-hint">Supports CSV format or custom format (Name: URL)</div>
</div>
<div class="form-group">
<label class="form-label">Default Mode</label>
<select id="importMode" class="form-select" onchange="onImportModeChange()">
<option value="permanent">Permanent (Long-term)</option>
<option value="temporary">Temporary (Short-term)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Default Expiration</label>
<div id="importPermanentExpiration">
<select id="importPermanentExp" class="form-select">
<option value="7d">7 Days</option>
<option value="1m">1 Month</option>
<option value="3m">3 Months</option>
<option value="1y" selected>1 Year</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<div id="importTemporaryExpiration" style="display: none;">
<select id="importTempExp" class="form-select">
<option value="1d">1 Day</option>
<option value="3d" selected>3 Days</option>
<option value="7d">7 Days</option>
</select>
</div>
</div>
<div id="importLoadingBar" class="loading-bar" style="display: none;">
<div class="loading-bar-inner"></div>
</div>
<div id="importStatus" class="import-status" style="display: none;"></div>
<div class="modal-footer">
<button id="importCancelBtn" class="btn-secondary" onclick="closeImportModal()">Cancel</button>
<button id="importSubmitBtn" class="btn-primary" onclick="executeImport()">Import</button>
</div>
</div>
</div>
<script>
let currentEditId = null;
let currentRenewId = null;
let currentRenewAction = 'renew';
let currentSelectedId = null;
let allSubscriptions = [];
let currentLinkType = 'clash';
let confirmCallback = null;
window.onload = loadSubscriptions;
// --- Custom Dialog ---
function showCustomAlert(message, title = 'Notice') {
document.getElementById('customAlertTitle').textContent = title;
// Support newlines in message by using innerHTML with escaped content
const escapedMessage = message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\\n/g, '<br>');
document.getElementById('customAlertMessage').innerHTML = escapedMessage;
document.getElementById('customAlertModal').classList.add('active');
}
function closeCustomAlert() {
document.getElementById('customAlertModal').classList.remove('active');
}
function showCustomConfirm(message, callback, title = 'Confirm Action') {
document.getElementById('customConfirmTitle').textContent = title;
document.getElementById('customConfirmMessage').textContent = message;
confirmCallback = callback;
document.getElementById('customConfirmModal').classList.add('active');
}
function closeCustomConfirm() {
document.getElementById('customConfirmModal').classList.remove('active');
confirmCallback = null;
}
// --- Overlay Loading ---
function showOverlayLoading(message) {
let overlay = document.getElementById('overlayLoading');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'overlayLoading';
overlay.className = 'overlay-loading';
overlay.innerHTML = '<div class="spinner"></div><div class="overlay-text"></div>';
document.body.appendChild(overlay);
}
overlay.querySelector('.overlay-text').textContent = message || 'Processing...';
overlay.style.display = 'flex';
}
function hideOverlayLoading() {
const overlay = document.getElementById('overlayLoading');
if (overlay) overlay.style.display = 'none';
}
function executeCustomConfirm() {
document.getElementById('customConfirmModal').classList.remove('active');
if (confirmCallback) confirmCallback();
confirmCallback = null;
}
// --- Logic ---
function onSubscriptionModeChange() {
const mode = document.getElementById('subMode').value;
document.getElementById('permanentExpiration').style.display = mode === 'permanent' ? 'block' : 'none';
document.getElementById('temporaryExpiration').style.display = mode === 'temporary' ? 'block' : 'none';
}
async function loadSubscriptions() {
try {
const res = await fetch('/api/subscriptions');
const data = await res.json();
allSubscriptions = data.subscriptions || [];
renderList();
if (allSubscriptions.length > 0 && !currentSelectedId) {
selectSubscription(allSubscriptions[0].id);
} else if (allSubscriptions.length === 0) {
showEmptyState();
}
} catch (e) { console.error(e); }
}
function renderList() {
const container = document.getElementById('subscriptionList');
if (allSubscriptions.length === 0) {
container.innerHTML = '<div style="text-align:center; padding:32px; color:var(--text-tertiary);">No subscriptions found</div>';
return;
}
container.innerHTML = allSubscriptions.map(sub => {
const active = sub.id === currentSelectedId ? 'active' : '';
const typeText = sub.subscriptionType === 'v2ray' ? 'V2Ray' : 'Clash';
const typeClass = sub.subscriptionType === 'v2ray' ? 'badge-v2ray' : 'badge-clash';
const modeText = sub.subscriptionMode === 'temporary' ? 'TEMP' : 'PERM';
let statusHtml = '';
if (sub.expiresAt) {
const days = (sub.expiresAt - Date.now()) / 86400000;
if (sub.isExpired || days <= 0) {
statusHtml = '<span class="badge badge-expired">Expired</span>';
} else if (sub.subscriptionMode === 'temporary') {
// 临时订阅:<=3天时提示快过期
if (days <= 3) statusHtml = '<span class="badge badge-expiring">Expiring</span>';
} else {
// 长期订阅:<=15天时提示快过期
if (days <= 15) statusHtml = '<span class="badge badge-expiring">Expiring</span>';
}
}
return \`<div class="sub-item \${active}" onclick="selectSubscription('\${sub.id}')">
<div class="sub-header">
<div class="sub-name">\${escapeHtml(sub.name)}</div>
<div class="badge-group">
<span class="badge \${typeClass}">\${typeText}</span>
\${statusHtml}
</div>
</div>
<div class="sub-footer">
<span class="sub-info-text">\${modeText}</span>
<span class="sub-info-text">\${sub.expiresAt ? formatDateSimple(sub.expiresAt) : 'Unlimited'}</span>
</div>
</div>\`;
}).join('');
}
function selectSubscription(id) {
currentSelectedId = id;
renderList();
const sub = allSubscriptions.find(s => s.id === id);
if (!sub) return;
document.getElementById('emptyState').style.display = 'none';
document.getElementById('detailView').style.display = 'block';
document.getElementById('detailTitle').textContent = sub.name;
// Badges
const typeBadge = document.getElementById('detailTypeBadge');
typeBadge.className = 'badge ' + (sub.subscriptionType === 'v2ray' ? 'badge-v2ray' : 'badge-clash');
typeBadge.textContent = sub.subscriptionType === 'v2ray' ? 'V2Ray' : 'Clash';
const modeBadge = document.getElementById('detailModeBadge');
modeBadge.className = 'badge ' + (sub.subscriptionMode === 'temporary' ? 'badge-temp' : 'badge-perm');
modeBadge.textContent = sub.subscriptionMode === 'temporary' ? 'Temporary' : 'Permanent';
// Info
document.getElementById('detailSourceType').textContent = sub.subscriptionType === 'v2ray' ? 'V2Ray / VLESS / VMess' : 'Clash YAML';
document.getElementById('detailCreatedAt').textContent = formatDate(sub.createdAt);
document.getElementById('detailExpiration').textContent = sub.expiresAt ? formatDate(sub.expiresAt) : 'Unlimited';
document.getElementById('detailOriginalUrl').textContent = sub.clashUrl;
// 临时订阅隐藏续期和换新链接按钮
const linkActionRow = document.getElementById('linkActionRow');
if (linkActionRow) {
linkActionRow.style.display = sub.subscriptionMode === 'temporary' ? 'none' : 'flex';
}
// Link logic
const defaultType = sub.subscriptionType === 'v2ray' ? 'v2ray' : 'clash';
switchLinkType(defaultType); // Reset to default preference or keep state? Resetting is safer for now.
}
function showEmptyState() {
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('detailView').style.display = 'none';
currentSelectedId = null;
}
async function switchLinkType(type) {
if (!currentSelectedId) return;
currentLinkType = type;
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === type);
});
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
const shortId = sub?.shortIds?.[type];
const expiry = sub?.shortIdsExpiry?.[type];
if (shortId) {
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + shortId;
updateLinkStatus(expiry, sub);
} else {
generateLink(type);
}
}
async function generateLink(type) {
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
if (!sub) return;
const input = document.getElementById('detailShortUrl');
input.value = 'Generating...';
document.getElementById('linkProgressBar').style.width = '100%';
document.getElementById('linkProgressBar').style.backgroundColor = 'var(--warning)';
document.getElementById('linkProgressText').textContent = 'Processing';
try {
const res = await fetch('/api/subscriptions/' + currentSelectedId + '/renew', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expiration: sub.expiration || '7d',
linkType: type
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
input.value = data.shortUrl;
await loadSubscriptions();
const updated = allSubscriptions.find(s => s.id === currentSelectedId);
updateLinkStatus(updated?.shortIdsExpiry?.[type], updated);
} catch (e) {
input.value = 'Error generating link';
document.getElementById('linkProgressBar').style.backgroundColor = 'var(--danger)';
}
}
function updateLinkStatus(expiry, sub) {
const bar = document.getElementById('linkProgressBar');
const text = document.getElementById('linkProgressText');
if (!expiry) {
bar.style.width = '100%';
bar.style.backgroundColor = 'var(--success)';
text.textContent = 'Unlimited';
return;
}
const now = Date.now();
const remaining = expiry - now;
if (remaining <= 0) {
bar.style.width = '0%';
bar.style.backgroundColor = 'var(--danger)';
text.textContent = 'Expired';
return;
}
// 根据订阅的实际有效期计算总时长
const expiration = sub?.expiration || '7d';
let totalMs = 7 * 86400000; // 默认7天
if (expiration === '1d') totalMs = 1 * 86400000;
else if (expiration === '3d') totalMs = 3 * 86400000;
else if (expiration === '7d') totalMs = 7 * 86400000;
else if (expiration === '1m') totalMs = 30 * 86400000;
else if (expiration === '3m') totalMs = 90 * 86400000;
else if (expiration === '1y') totalMs = 365 * 86400000;
else if (expiration === 'unlimited') totalMs = remaining; // 无限期时显示100%
const pct = Math.min(100, Math.max(5, (remaining / totalMs) * 100));
// 根据订阅模式决定警告阈值
const isTemporary = sub?.subscriptionMode === 'temporary';
const days = remaining / 86400000;
let barColor = 'var(--success)';
if (isTemporary) {
// 临时订阅:<=1天危险<=3天警告
if (days <= 1) barColor = 'var(--danger)';
else if (days <= 3) barColor = 'var(--warning)';
} else {
// 长期订阅:<=7天危险<=15天警告
if (days <= 7) barColor = 'var(--danger)';
else if (days <= 15) barColor = 'var(--warning)';
}
bar.style.width = pct + '%';
bar.style.backgroundColor = barColor;
const daysInt = Math.ceil(days);
text.textContent = daysInt > 1 ? (daysInt + ' Days') : (Math.ceil(remaining / 3600000) + ' Hours');
}
// Actions
function showAddModal() {
document.getElementById('modalTitle').textContent = 'New Subscription';
document.getElementById('subName').value = '';
document.getElementById('subClashUrl').value = '';
document.getElementById('subMode').value = 'permanent';
onSubscriptionModeChange();
// Show expiration field for new subscriptions
document.getElementById('expirationGroup').style.display = 'block';
currentEditId = null;
document.getElementById('subscriptionModal').classList.add('active');
}
function editSubscription() {
if (!currentSelectedId) return;
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
document.getElementById('modalTitle').textContent = 'Edit Subscription';
document.getElementById('subName').value = sub.name;
document.getElementById('subClashUrl').value = sub.clashUrl;
document.getElementById('subMode').value = sub.subscriptionMode || 'permanent';
onSubscriptionModeChange();
// Show expiration field when editing and set current value
document.getElementById('expirationGroup').style.display = 'block';
const currentExpiration = sub.expiration || '7d';
if (sub.subscriptionMode === 'temporary') {
document.getElementById('subTempExpiration').value = ['1d', '3d', '7d'].includes(currentExpiration) ? currentExpiration : '3d';
} else {
document.getElementById('subPermanentExpiration').value = ['7d', '1m', '3m', '1y', 'unlimited'].includes(currentExpiration) ? currentExpiration : '1y';
}
currentEditId = currentSelectedId;
document.getElementById('subscriptionModal').classList.add('active');
}
async function saveSubscription() {
const name = document.getElementById('subName').value.trim();
const url = document.getElementById('subClashUrl').value.trim();
const mode = document.getElementById('subMode').value;
const expiration = mode === 'temporary'
? document.getElementById('subTempExpiration').value
: document.getElementById('subPermanentExpiration').value;
if (!name || !url) {
showCustomAlert('Please fill in all fields');
return;
}
const endpoint = currentEditId ? '/api/subscriptions/' + currentEditId : '/api/subscriptions';
const method = currentEditId ? 'PUT' : 'POST';
try {
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, clashUrl: url, expiration, subscriptionMode: mode })
});
const data = await res.json();
if (!res.ok) {
let errorMsg = data.error || 'Unknown error';
if (data.hint) {
errorMsg += '\\n\\n' + data.hint;
}
throw new Error(errorMsg);
}
// 调试:检查返回的 subscriptionMode
console.log('Saved subscription mode:', mode, 'Server returned:', data.subscription?.subscriptionMode);
const editedId = currentEditId;
closeModal();
await loadSubscriptions();
// 如果是编辑模式,重新选中该订阅以刷新详情页面
if (editedId) {
selectSubscription(editedId);
}
} catch (e) {
showCustomAlert('Failed: ' + e.message);
}
}
async function deleteCurrentSubscription() {
showCustomConfirm('Are you sure you want to delete this?', async () => {
await fetch('/api/subscriptions/' + currentSelectedId, { method: 'DELETE' });
currentSelectedId = null;
await loadSubscriptions();
});
}
async function clearAllSubscriptions() {
// Close dropdown if open
const dropdown = document.getElementById('dropdownMenu');
if (dropdown) dropdown.classList.remove('active');
showCustomConfirm('WARNING: This will delete ALL subscriptions! Continue?', async () => {
showOverlayLoading('Clearing all data...');
try {
await fetch('/api/subscriptions/clear-all', { method: 'POST' });
currentSelectedId = null;
await loadSubscriptions();
hideOverlayLoading();
showCustomAlert('All data cleared');
} catch (e) {
hideOverlayLoading();
showCustomAlert('Clear failed: ' + e.message);
}
});
}
// --- Import/Export Functions ---
function showImportModal() {
const dropdown = document.getElementById('dropdownMenu');
if (dropdown) dropdown.classList.remove('active');
document.getElementById('importContent').value = '';
document.getElementById('importMode').value = 'permanent';
onImportModeChange();
document.getElementById('importModal').classList.add('active');
}
function closeImportModal() {
document.getElementById('importModal').classList.remove('active');
// 重置loading状态
isImporting = false;
const submitBtn = document.getElementById('importSubmitBtn');
if (submitBtn) {
submitBtn.classList.remove('btn-loading');
submitBtn.textContent = 'Import';
}
const loadingBar = document.getElementById('importLoadingBar');
if (loadingBar) loadingBar.style.display = 'none';
const statusDiv = document.getElementById('importStatus');
if (statusDiv) statusDiv.style.display = 'none';
}
function onImportModeChange() {
const mode = document.getElementById('importMode').value;
document.getElementById('importPermanentExpiration').style.display = mode === 'permanent' ? 'block' : 'none';
document.getElementById('importTemporaryExpiration').style.display = mode === 'temporary' ? 'block' : 'none';
}
let isImporting = false;
async function executeImport() {
if (isImporting) return;
const content = document.getElementById('importContent').value.trim();
if (!content) {
showCustomAlert('Please enter import content');
return;
}
const mode = document.getElementById('importMode').value;
const expiration = mode === 'temporary'
? document.getElementById('importTempExp').value
: document.getElementById('importPermanentExp').value;
// 显示loading状态
isImporting = true;
const submitBtn = document.getElementById('importSubmitBtn');
const loadingBar = document.getElementById('importLoadingBar');
const statusDiv = document.getElementById('importStatus');
submitBtn.classList.add('btn-loading');
submitBtn.textContent = 'Importing...';
loadingBar.style.display = 'block';
statusDiv.style.display = 'block';
statusDiv.textContent = 'Processing subscriptions, please wait...';
try {
const res = await fetch('/api/subscriptions/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, expiration, subscriptionMode: mode })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Import failed');
}
closeImportModal();
await loadSubscriptions();
let message = data.message || 'Imported ' + (data.created ? data.created.length : 0) + ' subscriptions';
// 显示重复项信息
if (data.duplicates && data.duplicates.length > 0) {
message += '\\n\\nSkipped duplicates:\\n' + data.duplicates.slice(0, 5).map(d => d.name).join('\\n');
if (data.duplicates.length > 5) {
message += '\\n... and ' + (data.duplicates.length - 5) + ' more';
}
}
if (data.errors && data.errors.length > 0) {
message += '\\n\\nWarnings:\\n' + data.errors.slice(0, 5).join('\\n');
if (data.errors.length > 5) {
message += '\\n... and ' + (data.errors.length - 5) + ' more';
}
}
showCustomAlert(message, 'Import Complete');
} catch (e) {
showCustomAlert('Import failed: ' + e.message);
} finally {
// 恢复按钮状态
isImporting = false;
submitBtn.classList.remove('btn-loading');
submitBtn.textContent = 'Import';
loadingBar.style.display = 'none';
statusDiv.style.display = 'none';
}
}
async function exportSubscriptions() {
const dropdown = document.getElementById('dropdownMenu');
if (dropdown) dropdown.classList.remove('active');
showOverlayLoading('Exporting subscriptions...');
try {
const res = await fetch('/api/subscriptions/export');
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Export failed');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'subscriptions_' + new Date().toISOString().slice(0,10) + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
hideOverlayLoading();
showCustomAlert('Export successful!', 'Export');
} catch (e) {
hideOverlayLoading();
showCustomAlert('Export failed: ' + e.message);
}
}
function toggleDropdown() {
const dropdown = document.getElementById('dropdownMenu');
dropdown.classList.toggle('active');
}
function renewSubscription() { openRenewModal('renew'); }
function regenerateShortUrl() { openRenewModal('regenerate'); }
function openRenewModal(action) {
currentRenewAction = action;
currentRenewId = currentSelectedId;
const sub = allSubscriptions.find(s => s.id === currentSelectedId);
if (!sub) return;
document.getElementById('renewModalTitle').textContent = action === 'renew' ? 'Renew Validity' : 'Regenerate Token';
const isTemp = sub.subscriptionMode === 'temporary';
document.getElementById('renewPermanentExpiration').style.display = isTemp ? 'none' : 'block';
document.getElementById('renewTemporaryExpiration').style.display = isTemp ? 'block' : 'none';
document.getElementById('renewModal').classList.add('active');
}
async function confirmRenew() {
const sub = allSubscriptions.find(s => s.id === currentRenewId);
const expiration = sub.subscriptionMode === 'temporary'
? document.getElementById('renewTempExpiration').value
: document.getElementById('renewPermanentExp').value;
try {
await fetch(\`/api/subscriptions/\${currentRenewId}/\${currentRenewAction}\`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiration, linkType: currentLinkType })
});
closeRenewModal();
await loadSubscriptions();
// Refresh link view
const updated = allSubscriptions.find(s => s.id === currentRenewId);
document.getElementById('detailShortUrl').value = window.location.origin + '/s/' + updated.shortIds[currentLinkType];
updateLinkStatus(updated.shortIdsExpiry[currentLinkType], updated);
} catch (e) {
showCustomAlert('Operation failed');
}
}
// Utils
function closeModal() { document.getElementById('subscriptionModal').classList.remove('active'); }
function closeRenewModal() { document.getElementById('renewModal').classList.remove('active'); }
function copyShortUrl(e) {
const input = document.getElementById('detailShortUrl');
input.select();
document.execCommand('copy');
const btn = e.target;
const original = btn.textContent;
btn.textContent = 'Copied!';
btn.style.color = 'var(--success)';
setTimeout(() => {
btn.textContent = original;
btn.style.color = '';
}, 1000);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(ts) {
return new Date(ts).toLocaleString();
}
function formatDateSimple(ts) {
return new Date(ts).toLocaleDateString() + ' ' + new Date(ts).getHours() + ':' + new Date(ts).getMinutes().toString().padStart(2, '0');
}
window.onclick = function(e) {
if (e.target.classList.contains('modal')) e.target.classList.remove('active');
// Close dropdown if clicking outside
const dropdown = document.getElementById('dropdownMenu');
if (dropdown && !e.target.closest('.dropdown-container')) {
dropdown.classList.remove('active');
}
}
</script>
</body>
</html>`
}
function getAdminHTML() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>短链管理</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f5f7fb; margin: 0; padding: 20px; }
.card { background: white; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); padding: 24px; max-width: 960px; margin: 0 auto; }
h1 { margin: 0 0 8px 0; font-size: 22px; color: #222; }
.sub { color: #666; margin-bottom: 18px; font-size: 14px; }
.status { padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; display: none; font-size: 14px; }
.status.success { background: #e6ffed; color: #1b7f3f; border: 1px solid #b7f5c6; }
.status.error { background: #ffeaea; color: #b42318; border: 1px solid #f5c2c0; }
.status.info { background: #e7f3ff; color: #0d4ea6; border: 1px solid #c3defc; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eef1f6; font-size: 13px; color: #333; }
th { background: #fafbff; font-weight: 600; }
.actions button { margin-right: 8px; padding: 6px 10px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; }
.btn { background: #667eea; color: white; }
.btn:hover { opacity: 0.9; }
.btn-secondary { background: #f1f3f9; color: #333; }
.toolbar { display: flex; gap: 10px; align-items: center; }
.pill { display: inline-block; padding: 6px 10px; background: #eef2ff; color: #4543c2; border-radius: 20px; font-size: 12px; margin-left: 10px; }
.small { color: #777; font-size: 12px; }
.nowrap { white-space: nowrap; }
</style>
</head>
<body>
<div class="card">
<h1>短链管理</h1>
<div class="sub">使用 token 访问本页。可续期、删除现有短链。</div>
<div class="toolbar">
<button id="refreshBtn" class="actions btn" onclick="loadKeys()">刷新列表</button>
<span class="pill">默认续期 7 天</span>
</div>
<div id="status" class="status"></div>
<table>
<thead>
<tr>
<th>ID</th>
<th class="nowrap">到期时间</th>
<th>原始 Clash 链接</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<div id="pagination" class="small" style="margin-top: 10px;"></div>
<div class="small" style="margin-top: 12px;">提示:当前列表最多显示 50 条,如需更多可使用 cursor 翻页。</div>
</div>
<script>
const urlParams = new URLSearchParams(location.search);
const adminToken = urlParams.get('token') || '';
const statusDiv = document.getElementById('status');
const tableBody = document.getElementById('tableBody');
const pagination = document.getElementById('pagination');
if (!adminToken) {
showStatus('缺少 token请在地址栏添加 ?token=你的管理口令', 'error');
} else {
loadKeys();
}
async function loadKeys(cursor = '') {
if (!adminToken) return;
try {
showStatus('加载中...', 'info');
const url = cursor ? '/admin/list?cursor=' + encodeURIComponent(cursor) : '/admin/list';
const resp = await fetch(url, { headers: { 'x-admin-token': adminToken } });
if (!resp.ok) throw new Error('加载失败');
const data = await resp.json();
renderTable(data.keys || []);
renderPagination(data.cursor, data.list_complete);
showStatus('加载完成', 'success');
} catch (e) {
showStatus(e.message || '加载失败', 'error');
}
}
function renderTable(keys) {
if (!keys.length) {
tableBody.innerHTML = '<tr><td colspan="4">暂无数据</td></tr>';
return;
}
tableBody.innerHTML = keys.map(k => {
const clash = k.metadata && k.metadata.clashUrl ? k.metadata.clashUrl : '-';
const exp = k.expiration ? formatTs(k.expiration * 1000) : '无';
return '<tr>' +
'<td class="nowrap">' + k.id + '</td>' +
'<td class="nowrap">' + exp + '</td>' +
'<td style="word-break: break-all;">' + clash + '</td>' +
'<td class="actions nowrap">' +
'<button class="btn" onclick="renew(\'' + k.id + '\')">续期</button>' +
'<button class="btn-secondary" onclick="removeLink(\'' + k.id + '\')">删除</button>' +
'</td>' +
'</tr>';
}).join('');
}
function renderPagination(cursor, complete) {
if (complete) {
pagination.textContent = '已到达末尾';
} else if (cursor) {
pagination.innerHTML = '有更多结果,点击 <button class="btn-secondary" onclick="loadKeys(\'' + cursor + '\')">加载下一页</button>';
} else {
pagination.textContent = '';
}
}
async function renew(id) {
const days = prompt('续期天数', '7');
if (days === null) return;
try {
const resp = await fetch('/admin/renew', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify({ id, days: Number(days) })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || '续期失败');
}
showStatus('续期成功', 'success');
loadKeys();
} catch (e) {
showStatus(e.message, 'error');
}
}
async function removeLink(id) {
if (!confirm('确认删除短链 ' + id + ' ?')) return;
try {
const resp = await fetch('/admin/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify({ id })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || '删除失败');
}
showStatus('删除成功', 'success');
loadKeys();
} catch (e) {
showStatus(e.message, 'error');
}
}
function formatTs(ts) {
const d = new Date(ts);
return d.toISOString().replace('T', ' ').slice(0, 19);
}
function showStatus(msg, type) {
statusDiv.textContent = msg;
statusDiv.className = 'status ' + type;
statusDiv.style.display = 'block';
}
</script>
</body>
</html>`
}