Skip to content
716 changes: 604 additions & 112 deletions src/app/admin/page.tsx

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions src/app/api/admin/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import {
emptyAnalyticsSummary,
getAdminPassword,
getAnalyticsDb,
getAnalyticsSummary,
getRuntimeEnv,
} from '@/lib/analytics';

export const dynamic = 'force-dynamic';
export const runtime = 'edge';

export async function GET(request: NextRequest) {
const env = getRuntimeEnv();
const authHeader = request.headers.get('Authorization');

if (authHeader !== getAdminPassword(env)) {
const response = NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
response.headers.set('Cache-Control', 'no-store');
return response;
}

const db = getAnalyticsDb(env);
if (!db) {
const response = NextResponse.json(emptyAnalyticsSummary());
response.headers.set('Cache-Control', 'no-store');
return response;
}

try {
const response = NextResponse.json(await getAnalyticsSummary(db));
response.headers.set('Cache-Control', 'no-store');
return response;
} catch (error) {
console.error('Failed to load analytics summary:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
6 changes: 5 additions & 1 deletion src/app/api/admin/auth/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'edge';

type AuthPayload = {
password?: string;
};

export async function POST(request: Request) {
try {
const { password } = await request.json();
const { password } = (await request.json()) as AuthPayload;
const adminPassword = process.env.ADMIN_PASSWORD || '';

if (password === adminPassword) {
Expand Down
42 changes: 28 additions & 14 deletions src/app/api/admin/delete/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { NextResponse } from 'next/server';
import { getRequestContext } from '@cloudflare/next-on-pages';
import {
getAdminPassword,
getAnalyticsDb,
getRuntimeEnv,
} from '@/lib/analytics';
import {
AssetCleanupResult,
deleteUnusedAssetKeys,
extractAssetKeysFromRecord,
getR2AssetsBucket,
} from '@/lib/r2-assets';

type AdminType = 'post' | 'daily' | 'moment' | 'comment';

Expand All @@ -8,21 +18,12 @@ export const runtime = 'edge';

export async function DELETE(request: Request) {
const isNode = typeof process.versions?.node !== 'undefined';
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
let db: any = null;

if (!isNode) {
try {
const { env } = getRequestContext();
db = (env as any).DB;
} catch (e) {
console.error('Failed to get D1 binding:', e);
}
}
const env = getRuntimeEnv();
const db = getAnalyticsDb(env);

try {
const authHeader = request.headers.get('Authorization');
const adminPassword = process.env.ADMIN_PASSWORD || '';
const adminPassword = getAdminPassword(env);

if (authHeader !== adminPassword) {
const res = NextResponse.json({ error: 'Unauthorized: Invalid security key' }, { status: 401 });
Expand Down Expand Up @@ -54,14 +55,23 @@ export async function DELETE(request: Request) {
}
}

let cleanupCandidates: string[] = [];
let assetCleanup: AssetCleanupResult | null = null;

// --- 数据库操作 ---
if (db) {
if (type === 'post') {
const slug = filename.replace('.md', '');
const target = await db.prepare('SELECT content FROM posts WHERE slug = ?').bind(slug).first<Record<string, unknown>>();
cleanupCandidates = extractAssetKeysFromRecord(target);
await db.prepare('DELETE FROM posts WHERE slug = ?').bind(slug).run();
} else if (type === 'daily') {
const target = await db.prepare('SELECT content, image_url FROM daily WHERE filename = ?').bind(filename).first<Record<string, unknown>>();
cleanupCandidates = extractAssetKeysFromRecord(target);
await db.prepare('DELETE FROM daily WHERE filename = ?').bind(filename).run();
} else if (type === 'moment') {
const target = await db.prepare('SELECT content, image_url FROM moments WHERE filename = ?').bind(filename).first<Record<string, unknown>>();
cleanupCandidates = extractAssetKeysFromRecord(target);
await db.prepare('DELETE FROM moments WHERE filename = ?').bind(filename).run();
} else if (type === 'comment') {
console.log('Attempting to delete comment with identifier:', filename);
Expand All @@ -77,9 +87,13 @@ export async function DELETE(request: Request) {
console.error('Failed to parse comment filename for deletion (invalid parts count):', filename);
}
}

if (cleanupCandidates.length) {
assetCleanup = await deleteUnusedAssetKeys(cleanupCandidates, db, getR2AssetsBucket());
}
}

const res = NextResponse.json({ success: true });
const res = NextResponse.json({ success: true, assetCleanup });
res.headers.set('Cache-Control', 'no-store');
return res;

Expand Down
106 changes: 106 additions & 0 deletions src/app/api/admin/home-items/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import {
deleteHomeItem,
ensureHomeModuleSchema,
getHomeDb,
getHomeModules,
saveHomeBook,
saveHomeTool,
} from '@/lib/home-modules';
import { getAdminPassword, getRuntimeEnv } from '@/lib/analytics';
import {
deleteUnusedAssetKeys,
extractAssetKeysFromRecord,
getR2AssetsBucket,
} from '@/lib/r2-assets';

export const dynamic = 'force-dynamic';
export const runtime = 'edge';

type HomeItemType = 'tool' | 'book';

function unauthorized() {
const response = NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
response.headers.set('Cache-Control', 'no-store');
return response;
}

function requireAdmin(request: NextRequest) {
return request.headers.get('Authorization') === getAdminPassword(getRuntimeEnv());
}

function normalizeType(value: string | null): HomeItemType {
return value === 'book' ? 'book' : 'tool';
}

export async function GET(request: NextRequest) {
if (!requireAdmin(request)) return unauthorized();

const type = normalizeType(new URL(request.url).searchParams.get('type'));
const modules = await getHomeModules({ includeDisabled: true });
const items = type === 'tool'
? modules.tools.map((tool) => ({ ...tool, filename: String(tool.id || '') }))
: modules.books.map((book) => ({ ...book, filename: String(book.id || '') }));

const response = NextResponse.json({ items });
response.headers.set('Cache-Control', 'no-store');
return response;
}

export async function POST(request: NextRequest) {
if (!requireAdmin(request)) return unauthorized();

const db = getHomeDb();
if (!db) {
return NextResponse.json({ error: 'D1 database not available' }, { status: 500 });
}

const body = await request.json() as { type?: HomeItemType; data?: Record<string, unknown> };
const type = body.type === 'book' ? 'book' : 'tool';

if (!body.data) {
return NextResponse.json({ error: 'Missing data' }, { status: 400 });
}

const id = type === 'tool'
? await saveHomeTool(db, body.data)
: await saveHomeBook(db, body.data);

const response = NextResponse.json({ success: true, id });
response.headers.set('Cache-Control', 'no-store');
return response;
}

export async function DELETE(request: NextRequest) {
if (!requireAdmin(request)) return unauthorized();

const db = getHomeDb();
if (!db) {
return NextResponse.json({ error: 'D1 database not available' }, { status: 500 });
}

const body = await request.json() as { type?: HomeItemType; id?: number | string };
const type = body.type === 'book' ? 'book' : 'tool';
const id = Number(body.id);

if (!Number.isFinite(id) || id <= 0) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}

await ensureHomeModuleSchema(db);

const target = type === 'book'
? await db.prepare('SELECT cover FROM home_books WHERE id = ?').bind(id).first<Record<string, unknown>>()
: null;
const cleanupCandidates = extractAssetKeysFromRecord(target);

await deleteHomeItem(db, type, id);

const assetCleanup = cleanupCandidates.length
? await deleteUnusedAssetKeys(cleanupCandidates, db, getR2AssetsBucket())
: null;

const response = NextResponse.json({ success: true, assetCleanup });
response.headers.set('Cache-Control', 'no-store');
return response;
}
93 changes: 93 additions & 0 deletions src/app/api/admin/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAdminPassword, getRuntimeEnv } from '@/lib/analytics';

export const dynamic = 'force-dynamic';
export const runtime = 'edge';

type R2BucketBinding = {
put: (
key: string,
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null,
options?: { httpMetadata?: { contentType?: string } }
) => Promise<unknown>;
};

const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
]);

const EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};

function unauthorized() {
const response = NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
response.headers.set('Cache-Control', 'no-store');
return response;
}

function getAssetsBucket(): R2BucketBinding | null {
const env = getRuntimeEnv() as { R2_ASSETS?: R2BucketBinding };
return env.R2_ASSETS ?? null;
}

function normalizeFolder(value: FormDataEntryValue | null) {
const folder = typeof value === 'string' ? value : 'uploads';
if (folder === 'books' || folder === 'posts' || folder === 'daily' || folder === 'moments') return folder;
return 'uploads';
}

function randomId() {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}

export async function POST(request: NextRequest) {
if (request.headers.get('Authorization') !== getAdminPassword(getRuntimeEnv())) {
return unauthorized();
}

const bucket = getAssetsBucket();
if (!bucket) {
return NextResponse.json({ error: 'R2 binding R2_ASSETS is not available' }, { status: 500 });
}

const formData = await request.formData();
const file = formData.get('file');

if (!(file instanceof File)) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}

if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
return NextResponse.json({ error: 'Unsupported image type' }, { status: 400 });
}

if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json({ error: 'Image exceeds 8MB limit' }, { status: 400 });
}

const folder = normalizeFolder(formData.get('folder'));
const extension = EXTENSIONS[file.type] || 'bin';
const key = `${folder}/${Date.now()}-${randomId()}.${extension}`;

await bucket.put(key, file.stream(), {
httpMetadata: { contentType: file.type },
});

const response = NextResponse.json({
success: true,
key,
url: `/api/assets/${key}`,
});
response.headers.set('Cache-Control', 'no-store');
return response;
}
58 changes: 58 additions & 0 deletions src/app/api/analytics/view/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import {
ensureAnalyticsSchema,
getAnalyticsDb,
getClientIp,
getRuntimeEnv,
hashVisitor,
normalizePath,
shouldTrackPath,
} from '@/lib/analytics';

export const dynamic = 'force-dynamic';
export const runtime = 'edge';

type ViewPayload = {
path?: unknown;
referrer?: unknown;
};

export async function POST(request: NextRequest) {
const env = getRuntimeEnv();
const db = getAnalyticsDb(env);

if (!db) {
return NextResponse.json({ tracked: false, reason: 'D1 database not available' }, { status: 202 });
}

let payload: ViewPayload = {};
try {
payload = (await request.json()) as ViewPayload;
} catch {
payload = {};
}

const path = normalizePath(payload.path);
if (!shouldTrackPath(path)) {
return NextResponse.json({ tracked: false, reason: 'path excluded' });
}

const userAgent = request.headers.get('user-agent') || 'unknown';
const acceptLanguage = request.headers.get('accept-language') || '';
const ip = getClientIp(request);
const visitorHash = await hashVisitor(`${ip}:${userAgent}:${acceptLanguage}`, env);
const referrer = typeof payload.referrer === 'string' ? payload.referrer.slice(0, 500) : '';

try {
await ensureAnalyticsSchema(db);
await db.prepare(`
INSERT INTO analytics_events (path, visitor_hash, referrer)
VALUES (?, ?, ?)
`).bind(path, visitorHash, referrer).run();

return NextResponse.json({ tracked: true });
} catch (error) {
console.error('Analytics view tracking failed:', error);
return NextResponse.json({ tracked: false, error: 'Internal server error' }, { status: 500 });
}
}
Loading