// 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 }); }