mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
18
src/lib/admin-api.ts
Normal file
18
src/lib/admin-api.ts
Normal 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
49
src/lib/agent-auth.ts
Normal 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
25
src/lib/api-response.ts
Normal 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
55
src/lib/auth.ts
Normal 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
30
src/lib/crypto.ts
Normal 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
14
src/lib/errors.ts
Normal 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
47
src/lib/fetch-json.ts
Normal 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));
|
||||
}
|
||||
29
src/lib/fetch-with-timeout.ts
Normal file
29
src/lib/fetch-with-timeout.ts
Normal 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
15
src/lib/prisma.ts
Normal 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
52
src/lib/rate-limit.ts
Normal 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
36
src/lib/redis.ts
Normal 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
18
src/lib/require-auth.ts
Normal 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
100
src/lib/trace-normalize.ts
Normal 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
11
src/lib/turnstile.ts
Normal 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
40
src/lib/utils.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user