310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
// 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 });
|
||
}
|