mirror of
https://github.com/ermaozi/get_subscribe.git
synced 2026-09-29 19:51:41 +00:00
164 lines
7.4 KiB
JavaScript
164 lines
7.4 KiB
JavaScript
const REPOSITORY = 'ermaozi/get_subscribe';
|
|
const PREFIX = 'files/subscribe/';
|
|
const CACHE_SECONDS = 300;
|
|
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
const FILES = ['clash.yml', 'v2ray.txt', ...['reachable', 'unreachable', 'untested']
|
|
.flatMap(group => [`${group}/clash.yml`, `${group}/v2ray.txt`]), 'health.json'];
|
|
|
|
function json(body, status = 200) {
|
|
return Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } });
|
|
}
|
|
|
|
function fileForPath(path) {
|
|
if (path === '/sub/health.json') return 'health.json';
|
|
const match = path.match(/^\/sub\/(?:(reachable|unreachable|untested)\/)?(clash|v2ray)\/ermao\.net$/);
|
|
return match ? `${match[1] ? match[1] + '/' : ''}${match[2] === 'clash' ? 'clash.yml' : 'v2ray.txt'}` : null;
|
|
}
|
|
|
|
function contentType(file) {
|
|
if (file.endsWith('.json')) return 'application/json; charset=utf-8';
|
|
return file.endsWith('.yml') ? 'text/yaml; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
}
|
|
|
|
async function fetchText(url, limit = MAX_FILE_BYTES) {
|
|
const response = await fetch(url, {
|
|
headers: { 'User-Agent': 'ermao-subscription-sync' },
|
|
redirect: 'manual', signal: AbortSignal.timeout(20000),
|
|
cf: { cacheTtl: 0, cacheEverything: false },
|
|
});
|
|
if (response.status !== 200) {
|
|
await response.body?.cancel();
|
|
throw new Error(`Upstream HTTP ${response.status}`);
|
|
}
|
|
// 只读取有明确大小上限的文本,防止异常响应耗尽 Worker 内存。
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return { text: '', bytes: 0 };
|
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
let text = '', bytes = 0;
|
|
try {
|
|
while (true) {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) break;
|
|
bytes += chunk.value.byteLength;
|
|
if (bytes > limit) throw new Error('Upstream file exceeds size limit');
|
|
text += decoder.decode(chunk.value, { stream: true });
|
|
}
|
|
text += decoder.decode();
|
|
return { text, bytes };
|
|
} finally {
|
|
await reader.cancel();
|
|
}
|
|
}
|
|
|
|
function validate(file, text) {
|
|
if (file === 'health.json') {
|
|
const report = JSON.parse(text);
|
|
if (!Number.isFinite(Date.parse(report.checked_at)) || report.method !== 'TCP connect') {
|
|
throw new Error('Invalid health report');
|
|
}
|
|
for (const group of ['reachable', 'unreachable', 'untested']) {
|
|
for (const kind of ['clash', 'v2ray']) {
|
|
const count = report.counts?.[group]?.[kind];
|
|
if (!Number.isInteger(count) || count < 0) throw new Error('Invalid health counts');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// YAML 结构已由主仓库回归测试和采集程序检查;此处防止错误页覆盖订阅。
|
|
if (file.endsWith('.yml')) {
|
|
if (!/^proxies:\s/m.test(text)) throw new Error('Invalid Clash artifact');
|
|
} else {
|
|
const nodes = text.trim() ? text.trim().split(/\r?\n/) : [];
|
|
if ((!file.includes('/') && !nodes.length) || nodes.some(node =>
|
|
!/^(vmess|vless|trojan|ss|ssr|hysteria2?|hy2|tuic|anytls|mieru|https?|socks[45]?):\/\/\S+$/.test(node))) {
|
|
throw new Error('Invalid V2Ray artifact');
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function refreshAll(env) {
|
|
if (!env.SUBSCRIBE_BUCKET) throw new Error('Missing subscription storage');
|
|
const ref = await fetchText(`https://api.github.com/repos/${REPOSITORY}/git/ref/heads/main`, 16384);
|
|
const commit = JSON.parse(ref.text).object?.sha;
|
|
if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('Invalid upstream commit');
|
|
const previous = await env.SUBSCRIBE_BUCKET.head(PREFIX + 'health.json');
|
|
if (previous?.customMetadata?.commit === commit) {
|
|
return { ok: true, commit, files: FILES.length, unchanged: true, updatedAt: previous.customMetadata.updatedAt };
|
|
}
|
|
const downloads = [];
|
|
let totalBytes = 0;
|
|
// 同一次同步固定到同一提交,所有文件验证完成后才写入 R2。
|
|
for (const file of FILES) {
|
|
const url = `https://raw.githubusercontent.com/${REPOSITORY}/${commit}/subscribe/${file}`;
|
|
const body = await fetchText(url);
|
|
totalBytes += body.bytes;
|
|
if (totalBytes > 16 * 1024 * 1024) throw new Error('Subscription batch exceeds size limit');
|
|
validate(file, body.text);
|
|
downloads.push({ file, url, text: body.text });
|
|
}
|
|
const updatedAt = new Date().toISOString();
|
|
for (const { file, url, text } of downloads) {
|
|
await env.SUBSCRIBE_BUCKET.put(PREFIX + file, text, {
|
|
httpMetadata: { contentType: contentType(file), cacheControl: `public, max-age=0, s-maxage=${CACHE_SECONDS}` },
|
|
customMetadata: { commit, source: url, updatedAt },
|
|
});
|
|
}
|
|
console.log(JSON.stringify({ event: 'subscription_sync', commit, files: downloads.length, bytes: totalBytes }));
|
|
return { ok: true, commit, files: downloads.length, updatedAt };
|
|
}
|
|
|
|
async function serve(request, env, file) {
|
|
let object = await env.SUBSCRIBE_BUCKET.get(PREFIX + file);
|
|
if (!object) {
|
|
await refreshAll(env);
|
|
object = await env.SUBSCRIBE_BUCKET.get(PREFIX + file);
|
|
}
|
|
if (!object) throw new Error('Subscription not available');
|
|
const headers = new Headers({
|
|
'Content-Type': contentType(file), 'Cache-Control': `public, max-age=0, s-maxage=${CACHE_SECONDS}`,
|
|
'X-Source': 'r2', 'ETag': object.httpEtag,
|
|
});
|
|
if (file !== 'health.json') {
|
|
const group = file.includes('/') ? file.split('/')[0] : 'all';
|
|
const label = { all: '免费订阅', reachable: 'TCP 可连接', unreachable: '连接失败', untested: '未检测' }[group];
|
|
const title = `ermao.net · ${label}`;
|
|
headers.set('Profile-Title', 'base64:' + btoa(String.fromCharCode(...new TextEncoder().encode(title))));
|
|
headers.set('Profile-Web-Page-Url', 'https://www.ermao.net/');
|
|
headers.set('Support-Url', 'https://www.ermao.net/');
|
|
headers.set('Content-Disposition', `attachment; filename="ermao.net-${file.replaceAll('/', '-')}"`);
|
|
}
|
|
const metadata = object.customMetadata || {};
|
|
if (/^[a-f0-9]{40}$/.test(metadata.commit)) headers.set('X-Git-Commit', metadata.commit);
|
|
if (metadata.updatedAt) headers.set('X-Updated-At', metadata.updatedAt.replace(/[\r\n]/g, ''));
|
|
if (request.headers.get('If-None-Match') === object.httpEtag) return new Response(null, { status: 304, headers });
|
|
return new Response(request.method === 'HEAD' ? null : object.body, { headers });
|
|
}
|
|
|
|
export default {
|
|
async fetch(request, env) {
|
|
if (!['GET', 'HEAD'].includes(request.method)) return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
|
|
const path = new URL(request.url).pathname.toLowerCase();
|
|
if (['/', '/health', '/sub/health'].includes(path)) {
|
|
return json({ ok: true, service: 'subscribe-worker', version: 'github-sync-v1', endpoints: [
|
|
'/sub/clash/ermao.net', '/sub/v2ray/ermao.net', '/sub/reachable/clash/ermao.net', '/sub/health.json',
|
|
] });
|
|
}
|
|
const refresh = path.match(/^\/sub\/refresh\/(all|clash|v2ray)$/);
|
|
const file = refresh && refresh[1] !== 'all' ? `${refresh[1] === 'clash' ? 'clash.yml' : 'v2ray.txt'}` : fileForPath(path);
|
|
if (!refresh && !file) return new Response('Not Found', { status: 404 });
|
|
try {
|
|
if (refresh) {
|
|
const result = await refreshAll(env);
|
|
if (refresh[1] === 'all') return json(result);
|
|
}
|
|
return await serve(request, env, file);
|
|
} catch (error) {
|
|
console.error(JSON.stringify({ event: 'subscription_error', message: String(error.message || error) }));
|
|
return json({ ok: false, error: 'Subscription refresh or storage unavailable' }, 502);
|
|
}
|
|
},
|
|
async scheduled(_controller, env, ctx) {
|
|
ctx.waitUntil(refreshAll(env));
|
|
},
|
|
};
|