shumengya mail@smyhub.com
This commit is contained in:
1504
cf-nav-backend/package-lock.json
generated
Normal file
1504
cf-nav-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
cf-nav-backend/package.json
Normal file
12
cf-nav-backend/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "cf-nav-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.68.1"
|
||||
}
|
||||
}
|
||||
309
cf-nav-backend/worker.js
Normal file
309
cf-nav-backend/worker.js
Normal 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 });
|
||||
}
|
||||
15
cf-nav-backend/wrangler.toml
Normal file
15
cf-nav-backend/wrangler.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
name = "cf-nav-backend"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2026-01-01"
|
||||
|
||||
# KV 命名空间(与原先 cf-nav 使用同一套数据可复用同一 id)
|
||||
[[kv_namespaces]]
|
||||
binding = "NAV_KV"
|
||||
id = "a89f429e1a684d2084eae8619755ee11"
|
||||
|
||||
# 管理令牌/密码:与前端 config.js 的 ADMIN_TOKEN 一致;请求头带 Authorization: Bearer <本值> 才能写数据(也可用 wrangler secret put ADMIN_PASSWORD 设置)
|
||||
[vars]
|
||||
ADMIN_PASSWORD = "shumengya5201314"
|
||||
FAVICON_API = "https://cf-favicon.pages.dev/api/favicon?url="
|
||||
|
||||
|
||||
Reference in New Issue
Block a user