Files
mengya-nav/cf-nav-backend/worker.js
2026-03-11 20:46:24 +08:00

310 lines
12 KiB
JavaScript
Raw 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 - 仅 API 后端(前后端分离)
// 部署到 cf-nav-backend前端静态资源由 Cloudflare Pages 托管
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
function verifyAuth(request, env) {
const auth = request.headers.get('Authorization');
const token = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
if (!token) return false;
const prefix = 'Bearer ';
if (!auth || !auth.startsWith(prefix)) return false;
const provided = auth.slice(prefix.length).trim();
return provided === token;
}
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const url = new URL(request.url);
const path = url.pathname;
try {
if (path === '/api/sites') {
return handleSites(request, env, corsHeaders);
}
if (path === '/api/auth/check') {
return handleAuthCheck(request, env, corsHeaders);
}
if (path.match(/^\/api\/sites\/[^/]+\/click$/)) {
const id = path.split('/')[3];
return handleSiteClick(request, env, id, corsHeaders);
}
if (path.startsWith('/api/sites/')) {
const id = path.split('/')[3];
return handleSite(request, env, id, corsHeaders);
}
if (path === '/api/categories') {
return handleCategories(request, env, corsHeaders);
}
if (path.startsWith('/api/categories/')) {
const name = decodeURIComponent(path.split('/')[3] || '');
return handleCategory(request, env, name, corsHeaders);
}
if (path === '/api/favicon') {
return handleFavicon(request, env, corsHeaders);
}
return new Response('Not Found', { status: 404 });
} catch (error) {
return new Response('Internal Server Error: ' + error.message, {
status: 500,
headers: corsHeaders,
});
}
},
};
/** 校验管理员 token用于前端进入后台时确认链接有效 */
async function handleAuthCheck(request, env, corsHeaders) {
if (request.method !== 'GET') {
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
const token = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
if (!token) {
return new Response(JSON.stringify({ ok: false }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const auth = request.headers.get('Authorization');
const prefix = 'Bearer ';
if (!auth || !auth.startsWith(prefix)) {
return new Response(JSON.stringify({ ok: false }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const provided = auth.slice(prefix.length).trim();
if (provided !== token) {
return new Response(JSON.stringify({ ok: false }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ ok: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
async function handleFavicon(request, env, corsHeaders) {
const url = new URL(request.url);
const domain = url.searchParams.get('domain');
if (!domain) {
return new Response('Missing domain parameter', { status: 400, headers: corsHeaders });
}
const faviconApi = env.FAVICON_API || env.FAVICON;
if (faviconApi && faviconApi !== '') {
const targetUrl = domain.startsWith('http') ? domain : `https://${domain}`;
try {
const res = await fetch(faviconApi + encodeURIComponent(targetUrl), {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
cf: { cacheTtl: 86400, cacheEverything: true },
});
if (res.ok) {
return new Response(res.body, {
status: res.status,
headers: {
...corsHeaders,
'Content-Type': res.headers.get('Content-Type') || 'image/x-icon',
'Cache-Control': 'public, max-age=86400',
},
});
}
} catch (_) {
/* 外置 API 失败时回退到内置源 */
}
}
const faviconSources = [
`https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
`https://favicon.api.shumengya.top/${domain}`,
];
for (const source of faviconSources) {
try {
const response = await fetch(source, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
cf: { cacheTtl: 86400, cacheEverything: true },
});
if (response.ok) {
return new Response(response.body, {
status: response.status,
headers: {
...corsHeaders,
'Content-Type': response.headers.get('Content-Type') || 'image/x-icon',
'Cache-Control': 'public, max-age=86400',
},
});
}
} catch (_) {
continue;
}
}
return new Response('Favicon not found', { status: 404, headers: corsHeaders });
}
async function handleSites(request, env, corsHeaders) {
if (request.method === 'GET') {
const sitesData = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const normalized = sitesData.map((s) => ({ ...s, clicks: typeof s.clicks === 'number' ? s.clicks : 0 }));
return new Response(JSON.stringify(normalized), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'POST') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const newSite = await request.json();
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
newSite.id = Date.now().toString();
newSite.clicks = 0;
sites.push(newSite);
if (newSite.category && !categories.includes(newSite.category)) {
categories.push(newSite.category);
await env.NAV_KV.put('categories', JSON.stringify(categories));
}
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify(newSite), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
async function handleSite(request, env, id, corsHeaders) {
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (request.method === 'GET') {
const site = sites.find((s) => s.id === id);
if (!site) return new Response('Not Found', { status: 404, headers: corsHeaders });
const normalized = { ...site, clicks: typeof site.clicks === 'number' ? site.clicks : 0 };
return new Response(JSON.stringify(normalized), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'PUT') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const updatedSite = await request.json();
const index = sites.findIndex((s) => s.id === id);
if (index === -1) return new Response('Not Found', { status: 404, headers: corsHeaders });
const existingClicks = typeof sites[index].clicks === 'number' ? sites[index].clicks : 0;
sites[index] = { ...sites[index], ...updatedSite, id, clicks: existingClicks };
if (updatedSite.category && !categories.includes(updatedSite.category)) {
categories.push(updatedSite.category);
await env.NAV_KV.put('categories', JSON.stringify(categories));
}
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify(sites[index]), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'DELETE') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const index = sites.findIndex((s) => s.id === id);
if (index === -1) return new Response('Not Found', { status: 404, headers: corsHeaders });
sites.splice(index, 1);
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
async function handleSiteClick(request, env, id, corsHeaders) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const index = sites.findIndex((s) => s.id === id);
if (index === -1) {
return new Response('Not Found', { status: 404, headers: corsHeaders });
}
const site = sites[index];
const prev = typeof site.clicks === 'number' ? site.clicks : 0;
sites[index] = { ...site, clicks: prev + 1 };
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify({ success: true, clicks: prev + 1 }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
async function handleCategories(request, env, corsHeaders) {
if (request.method === 'GET') {
let categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (!categories.length) {
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
categories = [...new Set(sites.map((s) => s.category).filter(Boolean))];
if (categories.length) await env.NAV_KV.put('categories', JSON.stringify(categories));
}
return new Response(JSON.stringify(categories), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'POST') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const { name } = await request.json();
if (!name || !name.trim()) {
return new Response('Bad Request', { status: 400, headers: corsHeaders });
}
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (!categories.includes(name.trim())) {
categories.push(name.trim());
await env.NAV_KV.put('categories', JSON.stringify(categories));
}
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
async function handleCategory(request, env, name, corsHeaders) {
if (!name) return new Response('Bad Request', { status: 400, headers: corsHeaders });
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (request.method === 'DELETE') {
const filtered = categories.filter((c) => c !== name);
await env.NAV_KV.put('categories', JSON.stringify(filtered));
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'PUT') {
const body = await request.json();
const newName = (body?.name || '').trim();
if (!newName) return new Response('Bad Request', { status: 400, headers: corsHeaders });
const updated = categories.map((c) => (c === name ? newName : c));
const unique = Array.from(new Set(updated));
await env.NAV_KV.put('categories', JSON.stringify(unique));
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const updatedSites = sites.map((site) =>
site.category === name ? { ...site, category: newName } : site
);
await env.NAV_KV.put('sites', JSON.stringify(updatedSites));
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}