Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

18
src/lib/admin-api.ts Normal file
View File

@@ -0,0 +1,18 @@
import { getServerSession } from "next-auth";
import { authOptions } from "./auth";
import { jsonError } from "./api-response";
export async function requireAdminApiSession() {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== "ADMIN") {
return {
session: null,
errorResponse: jsonError("无权限", { status: 401 }),
};
}
return {
session,
errorResponse: null,
};
}

49
src/lib/agent-auth.ts Normal file
View File

@@ -0,0 +1,49 @@
import { timingSafeEqual } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { decrypt } from "@/lib/crypto";
/**
* Authenticate an incoming Agent request by Bearer token.
* Returns the matched nodeId, or a 401 NextResponse on failure.
*/
export async function authenticateAgent(
req: Request,
): Promise<{ nodeId: string } | NextResponse> {
const authHeader = req.headers.get("authorization") || "";
if (!authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const token = authHeader.slice(7);
const nodes = await prisma.nodeServer.findMany({
where: { agentToken: { not: null } },
select: { id: true, agentToken: true },
});
const tokenBuf = Buffer.from(token);
for (const node of nodes) {
try {
const decrypted = decrypt(node.agentToken!);
const expectedBuf = Buffer.from(decrypted);
if (
tokenBuf.length === expectedBuf.length &&
timingSafeEqual(tokenBuf, expectedBuf)
) {
return { nodeId: node.id };
}
} catch {
// Skip nodes with corrupt tokens
}
}
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
/** Type guard: true when authenticateAgent returned an error response */
export function isAuthError(
result: { nodeId: string } | NextResponse,
): result is NextResponse {
return result instanceof NextResponse;
}

25
src/lib/api-response.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getErrorMessage } from "./errors";
export function jsonError(
error: unknown,
options?: {
status?: number;
fallback?: string;
headers?: HeadersInit;
},
) {
return NextResponse.json(
{
error: getErrorMessage(error, options?.fallback ?? "请求失败"),
},
{
status: options?.status ?? 500,
headers: options?.headers,
},
);
}
export function jsonOk<T>(data: T, init?: ResponseInit) {
return NextResponse.json(data, init);
}

55
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "./prisma";
import { verifyTurnstile } from "./turnstile";
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
turnstileToken: { label: "Turnstile", type: "text" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const config = await prisma.appConfig.findUnique({ where: { id: "default" } });
if (config?.turnstileSecretKey) {
const token = credentials.turnstileToken;
if (!token || !(await verifyTurnstile(token, config.turnstileSecretKey))) {
return null;
}
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user || user.status !== "ACTIVE") return null;
const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
],
session: { strategy: "jwt" },
pages: { signIn: "/login" },
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role: string }).role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as { id: string }).id = token.id as string;
(session.user as { role: string }).role = token.role as string;
}
return session;
},
},
};

30
src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,30 @@
import crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
function getKey() {
const raw = process.env.ENCRYPTION_KEY;
if (!raw || Buffer.byteLength(raw, "utf-8") < 32) {
throw new Error("ENCRYPTION_KEY must be at least 32 bytes");
}
return Buffer.from(raw, "utf-8").subarray(0, 32);
}
export function encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
}
export function decrypt(data: string): string {
const parts = data.split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted data format");
}
const [ivHex, authTagHex, encryptedHex] = parts;
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivHex, "hex"));
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
return decipher.update(Buffer.from(encryptedHex, "hex")) + decipher.final("utf8");
}

14
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,14 @@
export function getErrorMessage(
error: unknown,
fallback = "操作失败",
): string {
if (error instanceof Error && error.message.trim()) {
return error.message.trim();
}
if (typeof error === "string" && error.trim()) {
return error.trim();
}
return fallback;
}

47
src/lib/fetch-json.ts Normal file
View File

@@ -0,0 +1,47 @@
import { getErrorMessage } from "./errors";
function extractApiError(payload: unknown): string | null {
if (!payload || typeof payload !== "object") {
return null;
}
const error = (payload as { error?: unknown }).error;
return typeof error === "string" && error.trim() ? error.trim() : null;
}
export async function fetchJson<T>(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<T> {
const response = await fetch(input, init);
const raw = await response.text();
let payload: unknown = null;
if (raw) {
try {
payload = JSON.parse(raw) as unknown;
} catch {
throw new Error(
response.ok
? "服务器返回了无法解析的响应"
: `请求失败 (HTTP ${response.status})`,
);
}
}
if (!response.ok) {
throw new Error(
extractApiError(payload) ?? `请求失败 (HTTP ${response.status})`,
);
}
if (payload == null) {
throw new Error("服务器返回了空响应");
}
return payload as T;
}
export function toClientError(error: unknown, fallback: string): Error {
return new Error(getErrorMessage(error, fallback));
}

View File

@@ -0,0 +1,29 @@
export class TimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = "TimeoutError";
}
}
export async function fetchWithTimeout(
input: RequestInfo | URL,
init: RequestInit = {},
timeoutMs = 10_000,
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, {
...init,
signal: init.signal ?? controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new TimeoutError("请求超时");
}
throw error;
} finally {
clearTimeout(timeout);
}
}

15
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient, type Prisma } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma || createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export type DbClient = PrismaClient | Prisma.TransactionClient;

52
src/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,52 @@
import { getRedis } from "./redis";
import { randomUUID } from "crypto";
interface RateLimitResult {
success: boolean;
remaining: number;
reset: number;
}
/**
* Sliding window rate limiter using Redis.
* @param key - Unique identifier (e.g. `ratelimit:payment:${userId}`)
* @param limit - Max requests allowed in the window
* @param windowSeconds - Time window in seconds
*/
export async function rateLimit(
key: string,
limit: number,
windowSeconds: number,
): Promise<RateLimitResult> {
const now = Date.now();
const windowMs = windowSeconds * 1000;
try {
const redis = getRedis();
if (redis.status === "wait") {
await redis.connect();
}
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, now - windowMs);
pipeline.zadd(key, now, `${now}:${randomUUID()}`);
pipeline.zcard(key);
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const count = (results?.[2]?.[1] as number) ?? 0;
return {
success: count <= limit,
remaining: Math.max(0, limit - count),
reset: Math.ceil(windowSeconds - (now % (windowSeconds * 1000)) / 1000),
};
} catch {
// If Redis is unavailable, degrade gracefully instead of blocking user actions.
return {
success: true,
remaining: limit,
reset: windowSeconds,
};
}
}

36
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,36 @@
import Redis from "ioredis";
const globalForRedis = globalThis as unknown as {
redis?: Redis;
redisErrorBound?: boolean;
};
function createRedis() {
const client = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
lazyConnect: true,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
if (!globalForRedis.redisErrorBound) {
client.on("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[redis] ${message}`);
});
globalForRedis.redisErrorBound = true;
}
return client;
}
export function getRedis() {
if (!globalForRedis.redis) {
globalForRedis.redis = createRedis();
}
return globalForRedis.redis;
}
if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = globalForRedis.redis ?? createRedis();
}

18
src/lib/require-auth.ts Normal file
View File

@@ -0,0 +1,18 @@
import { getServerSession } from "next-auth";
import { authOptions } from "./auth";
export async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== "ADMIN") {
throw new Error("无权限");
}
return session;
}
export async function requireAuth() {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("未登录");
}
return session;
}

100
src/lib/trace-normalize.ts Normal file
View File

@@ -0,0 +1,100 @@
const CJK_PATTERN = /[\u3400-\u9fff]/g;
const MOJIBAKE_PATTERN = /[ÃÂÐÑØÙÚÛÜÝÞßæøåäö]/g;
function scoreText(value: string) {
return {
cjkCount: (value.match(CJK_PATTERN) ?? []).length,
mojibakeCount: (value.match(MOJIBAKE_PATTERN) ?? []).length,
replacementCount: (value.match(/<2F>/g) ?? []).length,
};
}
function chooseBetterText(original: string, candidate: string) {
if (!candidate || candidate === original) return original;
const originalScore = scoreText(original);
const candidateScore = scoreText(candidate);
if (candidateScore.replacementCount > originalScore.replacementCount) {
return original;
}
if (candidateScore.cjkCount > originalScore.cjkCount) {
return candidate;
}
if (candidateScore.mojibakeCount < originalScore.mojibakeCount) {
return candidate;
}
return original;
}
function decodeUnicodeEscapes(value: string) {
if (!/\\u[0-9a-fA-F]{4}|\\x[0-9a-fA-F]{2}/.test(value)) return value;
return value
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
}
export function normalizeTraceText(raw: unknown) {
if (raw == null) return "";
let text = String(raw).trim();
if (!text) return "";
if (/%[0-9a-fA-F]{2}/.test(text)) {
try {
text = chooseBetterText(text, decodeURIComponent(text));
} catch {
// ignore invalid URI encodings
}
}
text = chooseBetterText(text, decodeUnicodeEscapes(text));
try {
text = chooseBetterText(text, Buffer.from(text, "latin1").toString("utf8"));
} catch {
// ignore non-convertible content
}
return text.normalize("NFC");
}
function toSafeNumber(value: unknown, fallback: number) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return fallback;
return numberValue;
}
export interface NormalizedTraceHop {
hop: number;
ip: string;
geo: string;
latency: number;
}
export function normalizeTraceHops(hops: unknown): NormalizedTraceHop[] {
if (!Array.isArray(hops)) return [];
return hops
.map((hopItem, index) => {
const hopObject =
hopItem && typeof hopItem === "object"
? (hopItem as Record<string, unknown>)
: {};
return {
hop: Math.max(1, Math.trunc(toSafeNumber(hopObject.hop, index + 1))),
ip: normalizeTraceText(hopObject.ip),
geo: normalizeTraceText(hopObject.geo),
latency: Math.max(0, toSafeNumber(hopObject.latency, 0)),
};
})
.filter((hop) => hop.ip || hop.geo || hop.latency > 0);
}

11
src/lib/turnstile.ts Normal file
View File

@@ -0,0 +1,11 @@
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export async function verifyTurnstile(token: string, secretKey: string): Promise<boolean> {
const res = await fetch(VERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: secretKey, response: token }),
});
const data = await res.json();
return data.success === true;
}

40
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,40 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatBytes(bytes: number | bigint): string {
const b = Number(bytes);
if (b === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(b) / Math.log(k));
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
export function gbToBytes(gb: number): bigint {
return BigInt(Math.round(gb * 1024 * 1024 * 1024));
}
export function bytesToGb(bytes: bigint): number {
return Number(bytes) / (1024 * 1024 * 1024);
}
export function formatDate(date: Date | string): string {
return format(new Date(date), "yyyy-MM-dd HH:mm", { locale: zhCN });
}
export function formatDateShort(date: Date | string): string {
return format(new Date(date), "yyyy-MM-dd", { locale: zhCN });
}
export function parsePage(searchParams: Record<string, string | string[] | undefined>, pageSize = 20) {
const raw = searchParams.page;
const page = Math.max(1, parseInt(typeof raw === "string" ? raw : "1", 10) || 1);
const skip = (page - 1) * pageSize;
return { page, skip, pageSize };
}