chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:28 +08:00
parent c5af0cc946
commit 7477986036
66 changed files with 8009 additions and 5631 deletions

18
worker/src/api/auth.ts Normal file
View 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);
}

View 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
View 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',
};

View 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' },
});
}

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

View 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
View 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
View 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
View 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);
},
};

View 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
View 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"]
}