chore: sync local changes to Gitea
This commit is contained in:
18
worker/src/api/auth.ts
Normal file
18
worker/src/api/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NavEnv } from '../env';
|
||||
import { ADMIN_SESS_PREFIX } from './constants';
|
||||
|
||||
/** 校验 Bearer:明文密码或 KV 会话 token */
|
||||
export async function resolveAdminBearer(env: NavEnv, provided: string): Promise<boolean> {
|
||||
const secret = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
|
||||
if (!secret || !provided) return false;
|
||||
if (provided === secret) return true;
|
||||
const v = await env.NAV_KV.get(ADMIN_SESS_PREFIX + provided);
|
||||
return v != null && v !== '';
|
||||
}
|
||||
|
||||
export async function verifyAuth(request: Request, env: NavEnv): Promise<boolean> {
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (!auth || !auth.startsWith('Bearer ')) return false;
|
||||
const provided = auth.slice(7).trim();
|
||||
return resolveAdminBearer(env, provided);
|
||||
}
|
||||
2
worker/src/api/constants.ts
Normal file
2
worker/src/api/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const ADMIN_SESS_PREFIX = 'admin_sess:';
|
||||
export const ADMIN_SESS_TTL_SEC = 86400;
|
||||
6
worker/src/api/cors.ts
Normal file
6
worker/src/api/cors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** 跨域响应头(与旧版 Pages Functions 保持一致) */
|
||||
export const corsHeaders: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
};
|
||||
68
worker/src/api/handlers/auth-handlers.ts
Normal file
68
worker/src/api/handlers/auth-handlers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { NavEnv } from '../../env';
|
||||
import { resolveAdminBearer } from '../auth';
|
||||
import { corsHeaders } from '../cors';
|
||||
import { ADMIN_SESS_PREFIX, ADMIN_SESS_TTL_SEC } from '../constants';
|
||||
|
||||
export async function handleAuthLogin(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
const secret = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
|
||||
if (!secret) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 503,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
let body: { password?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (_) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const password = (body?.password != null ? String(body.password) : '').trim();
|
||||
if (password !== secret) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const sessionId = crypto.randomUUID();
|
||||
await env.NAV_KV.put(ADMIN_SESS_PREFIX + sessionId, '1', { expirationTtl: ADMIN_SESS_TTL_SEC });
|
||||
return new Response(JSON.stringify({ ok: true, sessionId }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAuthCheck(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method !== 'GET') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
const secret = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
|
||||
if (!secret) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const provided = auth.slice(7).trim();
|
||||
if (!(await resolveAdminBearer(env, provided))) {
|
||||
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' },
|
||||
});
|
||||
}
|
||||
69
worker/src/api/handlers/categories.ts
Normal file
69
worker/src/api/handlers/categories.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { NavEnv } from '../../env';
|
||||
import { verifyAuth } from '../auth';
|
||||
import { corsHeaders } from '../cors';
|
||||
import { getCategories, getSites, putCategories, putSites } from '../../storage/kv-nav';
|
||||
|
||||
export async function handleCategories(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method === 'GET') {
|
||||
let categories = await getCategories(env.NAV_KV);
|
||||
if (!categories.length) {
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
categories = [...new Set(sites.map((s) => s.category).filter(Boolean) as string[])];
|
||||
if (categories.length) await putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
return new Response(JSON.stringify(categories), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (request.method === 'POST') {
|
||||
if (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const { name } = (await request.json()) as { name?: string };
|
||||
if (!name || !name.trim()) {
|
||||
return new Response('Bad Request', { status: 400, headers: corsHeaders });
|
||||
}
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
if (!categories.includes(name.trim())) {
|
||||
categories.push(name.trim());
|
||||
await putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function handleCategory(request: Request, env: NavEnv, name: string): Promise<Response> {
|
||||
if (!name) return new Response('Bad Request', { status: 400, headers: corsHeaders });
|
||||
if (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
const filtered = categories.filter((c) => c !== name);
|
||||
await putCategories(env.NAV_KV, filtered);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
const body = (await request.json()) as { name?: string };
|
||||
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 putCategories(env.NAV_KV, unique);
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
const updatedSites = sites.map((site) =>
|
||||
site.category === name ? { ...site, category: newName } : site
|
||||
);
|
||||
await putSites(env.NAV_KV, updatedSites);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
97
worker/src/api/handlers/sites.ts
Normal file
97
worker/src/api/handlers/sites.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { NavEnv } from '../../env';
|
||||
import { verifyAuth } from '../auth';
|
||||
import { corsHeaders } from '../cors';
|
||||
import { getCategories, getSites, putCategories, putSites, type SiteRecord } from '../../storage/kv-nav';
|
||||
|
||||
export async function handleSites(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method === 'GET') {
|
||||
const sitesData = await getSites(env.NAV_KV);
|
||||
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 (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const newSite = (await request.json()) as SiteRecord;
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
newSite.id = Date.now().toString();
|
||||
newSite.clicks = 0;
|
||||
sites.push(newSite);
|
||||
if (newSite.category && !categories.includes(newSite.category)) {
|
||||
categories.push(newSite.category);
|
||||
await putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
await putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify(newSite), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function handleSite(request: Request, env: NavEnv, id: string): Promise<Response> {
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
|
||||
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 (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const updatedSite = (await request.json()) as Partial<SiteRecord>;
|
||||
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 putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
await putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify(sites[index]), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (request.method === 'DELETE') {
|
||||
if (!(await 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 putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function handleSiteClick(request: Request, env: NavEnv, id: string): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
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 putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify({ success: true, clicks: prev + 1 }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
50
worker/src/api/router.ts
Normal file
50
worker/src/api/router.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { NavEnv } from '../env';
|
||||
import { corsHeaders } from './cors';
|
||||
import { handleAuthCheck, handleAuthLogin } from './handlers/auth-handlers';
|
||||
import { handleCategories, handleCategory } from './handlers/categories';
|
||||
import { handleSite, handleSites, handleSiteClick } from './handlers/sites';
|
||||
|
||||
/** Worker `/api/*` 路由入口 */
|
||||
export async function handleApiRequest(request: Request, env: NavEnv): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
if (path === '/api/auth/login') {
|
||||
return handleAuthLogin(request, env);
|
||||
}
|
||||
if (path === '/api/auth/check') {
|
||||
return handleAuthCheck(request, env);
|
||||
}
|
||||
if (path.match(/^\/api\/sites\/[^/]+\/click$/)) {
|
||||
const id = path.split('/')[3]!;
|
||||
return handleSiteClick(request, env, id);
|
||||
}
|
||||
if (path.startsWith('/api/sites/')) {
|
||||
const id = path.split('/')[3]!;
|
||||
return handleSite(request, env, id);
|
||||
}
|
||||
if (path === '/api/categories') {
|
||||
return handleCategories(request, env);
|
||||
}
|
||||
if (path.startsWith('/api/categories/')) {
|
||||
const name = decodeURIComponent(path.split('/')[3] || '');
|
||||
return handleCategory(request, env, name);
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response('Internal Server Error: ' + message, {
|
||||
status: 500,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
worker/src/env.d.ts
vendored
Normal file
7
worker/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Worker 环境绑定(KV + 静态资源) */
|
||||
export interface NavEnv {
|
||||
NAV_KV: KVNamespace;
|
||||
ASSETS: Fetcher;
|
||||
ADMIN_PASSWORD?: string;
|
||||
ADMIN_TOKEN?: string;
|
||||
}
|
||||
17
worker/src/index.ts
Normal file
17
worker/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NavEnv } from './env';
|
||||
import { handleApiRequest } from './api/router';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: NavEnv): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
return handleApiRequest(request, env);
|
||||
}
|
||||
if (url.pathname === '/admin.html') {
|
||||
const dest = new URL('/admin', url.origin);
|
||||
dest.search = url.search;
|
||||
return Response.redirect(dest.toString(), 302);
|
||||
}
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
32
worker/src/storage/kv-nav.ts
Normal file
32
worker/src/storage/kv-nav.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** KV 中站点与分类的读写封装 */
|
||||
|
||||
export interface SiteRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
clicks?: number;
|
||||
}
|
||||
|
||||
const KEY_SITES = 'sites';
|
||||
const KEY_CATEGORIES = 'categories';
|
||||
|
||||
export async function getSites(kv: KVNamespace): Promise<SiteRecord[]> {
|
||||
const sitesData = (await kv.get(KEY_SITES, { type: 'json' })) as SiteRecord[] | null;
|
||||
return sitesData ?? [];
|
||||
}
|
||||
|
||||
export async function putSites(kv: KVNamespace, sites: SiteRecord[]): Promise<void> {
|
||||
await kv.put(KEY_SITES, JSON.stringify(sites));
|
||||
}
|
||||
|
||||
export async function getCategories(kv: KVNamespace): Promise<string[]> {
|
||||
const c = (await kv.get(KEY_CATEGORIES, { type: 'json' })) as string[] | null;
|
||||
return c ?? [];
|
||||
}
|
||||
|
||||
export async function putCategories(kv: KVNamespace, categories: string[]): Promise<void> {
|
||||
await kv.put(KEY_CATEGORIES, JSON.stringify(categories));
|
||||
}
|
||||
13
worker/tsconfig.json
Normal file
13
worker/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user