This commit is contained in:
2026-03-11 20:41:03 +08:00
commit c5af0cc946
21 changed files with 5831 additions and 0 deletions

309
cf-nav-backend/worker.js Normal file
View File

@@ -0,0 +1,309 @@
// 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 });
}