import fs from "node:fs"; import path from "node:path"; import { decryptText } from "@/lib/crypto"; import { getEnv } from "@/lib/env"; import type { Hy2NodeRow, Hy2UserRow, ProxyGroupConfig, } from "@/lib/store"; import { buildNodeAuthUrl, formatBytes } from "@/lib/store"; type ClashSettings = { template: string; extraProxyGroups: string; profileName: string; proxyGroups: ProxyGroupConfig[]; }; function readDefaultTemplate() { const filePath = path.join(process.cwd(), "app", "config", "config.yaml"); return fs.readFileSync(filePath, "utf8").trimEnd(); } function indent(text: string, spaces: number) { const prefix = " ".repeat(spaces); return text .split("\n") .map((line) => (line ? `${prefix}${line}` : line)) .join("\n"); } function renderProxy(node: Hy2NodeRow, authString: string) { const lines = [ `- name: "${node.client_name || node.name}"`, " type: hysteria2", ` server: ${node.server_host}`, ` port: ${node.server_port}`, ` password: "${authString}"`, ]; if (node.sni) { lines.push(` sni: ${node.sni}`); } if (node.skip_cert_verify) { lines.push(" skip-cert-verify: true"); } lines.push(" bandwidth:"); lines.push(` up: ${node.bandwidth_up_mbps} mbps`); lines.push(` down: ${node.bandwidth_down_mbps} mbps`); return lines.join("\n"); } function renderGroupEntry(name: string, proxyNames: string[]) { const quoted = proxyNames.map((name) => `"${name}"`).join(", "); return [ ` - name: ${name}`, " type: select", ` proxies: [${quoted}]`, ].join("\n"); } export function encodeRFC5987ValueChars(value: string) { return encodeURIComponent(value) .replace(/['()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`) .replace(/\*/g, "%2A") .replace(/%(7C|60|5E)/g, (_, code) => String.fromCharCode(Number.parseInt(code, 16)), ); } function buildAsciiFilenameFallback(value: string) { const ascii = value .normalize("NFKD") .replace(/[^\x20-\x7E]+/g, " ") .replace(/["\\]/g, "_") .replace(/\s+/g, " ") .trim(); return ascii || "subscription"; } export function buildSubscriptionHeaders(user: Hy2UserRow, profileName: string) { const expire = user.expires_at ? Math.round(new Date(user.expires_at).getTime() / 1000) : user.is_admin ? 4102444800 : Math.round(Date.now() / 1000) + 3600 * 24 * 365 * 10; const total = user.is_admin || user.traffic_limit_bytes == null ? "9223372036854775807" : String(user.traffic_limit_bytes); const fallbackFilename = buildAsciiFilenameFallback(profileName); return { "Content-Disposition": `attachment; filename="${fallbackFilename}"; filename*=UTF-8''${encodeRFC5987ValueChars(profileName)}`, "Content-Type": "text/yaml; charset=utf-8", "Profile-Update-Interval": "12", "subscription-userinfo": `upload=${user.used_tx_bytes}; download=${user.used_rx_bytes}; total=${total}; expire=${expire}`, }; } export function buildSubscriptionUrl(token: string, origin = getEnv().APP_URL) { return `${origin}/api/subscription/${token}/clash`; } export function buildNodeHy2Config(node: Hy2NodeRow, origin = getEnv().APP_URL) { const authUrl = buildNodeAuthUrl(node.slug, node.auth_token, origin); return [ `listen: ${node.hy2_listen || `:${node.server_port}`}`, "tls:", " cert: /etc/hysteria/server.crt", " key: /etc/hysteria/server.key", "auth:", " type: http", " http:", ` url: ${authUrl}`, " insecure: false", "trafficStats:", ` listen: ${node.traffic_stats_listen}`, ` secret: ${node.traffic_stats_secret}`, ].join("\n"); } export function buildSubscriptionConfig(input: { user: Hy2UserRow; nodes: Hy2NodeRow[]; settings: ClashSettings; }) { const authPassword = decryptText(input.user.auth_secret_encrypted); if (!authPassword) { throw new Error("user_secret_unavailable"); } const authString = `${input.user.username}:${authPassword}`; const eligibleNodes = input.nodes.filter( (node) => node.enabled && node.server_host && node.server_port > 0, ); const nodeNameBySlug = new Map( eligibleNodes.map((node) => [node.slug, node.client_name || node.name]), ); const proxyNames = eligibleNodes.map((node) => node.client_name || node.name); const proxiesBlock = eligibleNodes.map((node) => renderProxy(node, authString)).join("\n"); const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate(); const renderedGroups = [ renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames), ...input.settings.proxyGroups .map((group) => { const members = group.memberSlugs .map((slug) => nodeNameBySlug.get(slug)) .filter((name): name is string => Boolean(name)); if (members.length === 0) { return null; } return renderGroupEntry(group.name, members); }) .filter((group): group is string => Boolean(group)), ].join("\n"); const extraGroups = input.settings.extraProxyGroups .trim() .replace(/^proxy-groups:\s*/i, ""); return [ defaultTemplate, "", "proxies:", indent(proxiesBlock, 2), "", "proxy-groups:", renderedGroups, extraGroups ? `\n${extraGroups}` : "", "", ].join("\n"); } export function getDefaultClashTemplate() { return readDefaultTemplate(); } export function describeRemainingTraffic(user: Hy2UserRow) { if (user.is_admin || user.traffic_limit_bytes == null) { return "不限流量"; } const remaining = Math.max( user.traffic_limit_bytes - user.used_tx_bytes - user.used_rx_bytes, 0, ); return `${formatBytes(remaining)} 剩余`; }