npmrun 2 months ago
parent
commit
09b1661008
  1. 3
      .env.example
  2. 2
      app/components/index/CardFormModal.vue
  3. 60
      app/error.vue
  4. 13
      app/pages/auth/login.vue
  5. 62
      app/plugins/error-reporter.client.ts
  6. 3
      bun.lock
  7. 1
      package.json
  8. 2
      packages/common/config/index.ts
  9. BIN
      packages/drizzle-pkg/db.sqlite
  10. 3
      packages/env/schema.ts
  11. 3
      packages/logger/index.ts
  12. 38
      server/api/_error-report.post.ts
  13. 6
      server/api/auth/login.post.ts
  14. 5
      server/api/auth/oauth/[provider]/authorize.get.ts
  15. 12
      server/api/auth/oauth/[provider]/callback.get.ts
  16. 8
      server/api/auth/register.post.ts
  17. 7
      server/api/file/upload.post.ts
  18. 13
      server/api/llm/chat/index.post.ts
  19. 5
      server/middleware/10.auth-guard.ts
  20. 7
      server/plugins/00.req-time.ts
  21. 2
      server/plugins/20.error-logger.ts
  22. 40
      server/scheduler/tasks/cleanup-logs.ts
  23. 4
      server/service/auth/context.ts
  24. 2
      server/service/auth/index.ts
  25. 5
      server/service/chat/index.ts
  26. 21
      server/service/oauth/oauth-manager.ts
  27. 9
      server/utils/admin-guard.ts
  28. 2
      server/utils/handler.ts
  29. 5
      server/utils/simple-rate-limit.ts

3
.env.example

@ -11,4 +11,5 @@ GITEA_CLIENT_ID=your_gitea_client_id
GITEA_CLIENT_SECRET=your_gitea_client_secret
GITEA_URL=https://gitea.com
NITRO_PORT=3399
APP_URL=http://localhost:3399
APP_URL=http://localhost:3399
LOG_LEVEL=info

2
app/components/index/CardFormModal.vue

@ -181,7 +181,7 @@ async function onFileUpload(e: Event) {
})
imageUrl.value = raw.data[0].url
} catch (e) {
console.log(e)
console.error('[CardFormModal] 图片上传失败:', e)
error.value = '图片上传失败'
}
}

60
app/error.vue

@ -0,0 +1,60 @@
<template>
<div class="error-page">
<div class="error-card">
<div class="error-code">{{ statusCode }}</div>
<div class="error-message">{{ statusMessage }}</div>
<BoButton type="primary" @click="handleHome">返回首页</BoButton>
</div>
</div>
</template>
<script setup lang="ts">
import { BoButton } from 'bolt-ui'
const props = defineProps<{
statusCode: number
statusMessage?: string
}>()
const statusMessage = computed(() => {
if (props.statusMessage) return props.statusMessage
if (props.statusCode === 404) return '页面不存在'
if (props.statusCode >= 500) return '服务器开小差了'
return '出错了'
})
function handleHome() {
clearError({ redirect: '/' })
}
</script>
<style scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-canvas);
padding: 2rem;
}
.error-card {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.error-code {
font-family: var(--font-display);
font-size: 5rem;
color: var(--color-primary);
line-height: 1;
}
.error-message {
font-size: 1.125rem;
color: var(--color-body);
}
</style>

13
app/pages/auth/login.vue

@ -11,7 +11,6 @@ const redirect = computed(() => route.query.redirect as string || '/')
const loginForm = reactive({
username: '',
password: '',
rememberMe: false,
})
const captcha = reactive({
@ -136,14 +135,6 @@ onMounted(() => {
<div class="captcha-img" :class="{ loading: captcha.loading }" @click="fetchCaptcha" v-html="captcha.svg"></div>
</div>
<div class="form-options">
<label class="checkbox-field">
<input type="checkbox" v-model="loginForm.rememberMe" />
<span class="checkbox-custom"></span>
<span>记住我</span>
</label>
</div>
<button type="submit" class="submit-btn" :class="{ loading: loginLoading }" :disabled="loginLoading">
<span>{{ loginLoading ? '登录中...' : '登录' }}</span>
<span v-if="!loginLoading" class="btn-arrow">→</span>
@ -164,13 +155,13 @@ onMounted(() => {
<span>GitHub</span>
</button>
<!-- <button class="social-btn social-btn--gitea" @click="loginWithGitea">
<button class="social-btn social-btn--gitea" @click="loginWithGitea">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.5 10.5a.5.5 0 0 1 .5.5v1.5a.5.5 0 0 1-1 0V11a.5.5 0 0 1 .5-.5Zm18.5.5v1.5a.5.5 0 0 0 1 0V11a.5.5 0 0 0-1 0ZM12 15.5a1.5 1.5 0 0 1 1.5 1.5v1a.5.5 0 0 0 1 0v-1a2.5 2.5 0 0 0-5 0v1a.5.5 0 0 0 1 0v-1A1.5 1.5 0 0 1 12 15.5Z"/>
<path d="M20.5 12A8.5 8.5 0 0 0 12 3.5 8.5 8.5 0 0 0 3.5 12v5.25c0 .69.56 1.25 1.25 1.25h2.5c.69 0 1.25-.56 1.25-1.25V13.5c0-.69-.56-1.25-1.25-1.25h-2.5c-.09 0-.17.01-.25.02v-.27a8.5 8.5 0 0 1 17 0v.27a1.2 1.2 0 0 0-.25-.02h-2.5c-.69 0-1.25.56-1.25 1.25v3.75c0 .69.56 1.25 1.25 1.25H19.25c.69 0 1.25-.56 1.25-1.25V12Z"/>
</svg>
<span>Gitea</span>
</button> -->
</button>
</div>
<p v-if="globalConfig.allowRegister" class="form-footer">

62
app/plugins/error-reporter.client.ts

@ -0,0 +1,62 @@
interface ClientErrorPayload {
message: string;
stack?: string;
url?: string;
line?: number;
column?: number;
userAgent?: string;
tags?: string[];
}
let reportEndpoint = "/api/_error-report";
function reportError(payload: ClientErrorPayload) {
try {
$fetch(reportEndpoint, {
method: "POST",
body: payload,
}).catch(() => {});
} catch {
// 静默失败,避免错误上报本身导致循环
}
}
export default defineNuxtPlugin((nuxtApp) => {
// Vue 运行时错误
nuxtApp.vueApp.config.errorHandler = (err, _instance, info) => {
const error = err instanceof Error ? err : new Error(String(err));
reportError({
message: error.message,
stack: error.stack,
url: window.location.href,
userAgent: navigator.userAgent,
tags: ["vue", info],
});
};
// 未捕获的 Promise rejection
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
const error = reason instanceof Error ? reason : new Error(String(reason));
reportError({
message: error.message,
stack: error.stack,
url: window.location.href,
userAgent: navigator.userAgent,
tags: ["unhandledrejection"],
});
});
// 未捕获的同步错误
window.addEventListener("error", (event) => {
if (!event.message) return;
reportError({
message: event.message,
url: event.filename || window.location.href,
line: event.lineno,
column: event.colno,
userAgent: navigator.userAgent,
tags: ["window"],
});
});
});

3
bun.lock

@ -41,6 +41,7 @@
"zod": "4.3.6",
},
"devDependencies": {
"@iconify-json/ph": "^1.2.2",
"@nuxt/kit": "4.4.6",
"@tailwindcss/vite": "4.3.0",
"@types/multer": "2.1.0",
@ -237,6 +238,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@iconify-json/ph": ["@iconify-json/ph@1.2.2", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw=="],
"@iconify/collections": ["@iconify/collections@1.0.689", "https://registry.npmmirror.com/@iconify/collections/-/collections-1.0.689.tgz", { "dependencies": { "@iconify/types": "*" } }, "sha512-/XdTbQnsxfvpNvZvPCI2TGZHxem40PjUZ6IXoGA4M5QugVNN+l311l4PWyxECXOt95iFoKA8ZcHzyZVWFW9OFw=="],
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],

1
package.json

@ -55,6 +55,7 @@
"zod": "4.3.6"
},
"devDependencies": {
"@iconify-json/ph": "^1.2.2",
"@nuxt/kit": "4.4.6",
"@tailwindcss/vite": "4.3.0",
"@types/multer": "2.1.0",

2
packages/common/config/index.ts

@ -12,6 +12,8 @@ export const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/config/global", methods: ["GET"] },
{ path: "/api/auth/oauth/:provider/callback", methods: ["GET"] },
{ path: "/api/auth/oauth/:provider/authorize", methods: ["GET"] },
/** 客户端错误上报 — 公开(无需登录) */
{ path: "/api/_error-report", methods: ["POST"] },
/** 卡片列表 + 详情 */
{ path: "/api/cards", methods: ["GET"] },
{ path: "/api/cards/:id", methods: ["GET"] },

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

3
packages/env/schema.ts

@ -23,6 +23,9 @@ export const envSchema = z.object({
// ── Logging (logger package) ──
LOG_MAX_SIZE: z.coerce.number().int().positive().default(10 * 1024 * 1024), // 10 MB
LOG_MAX_BACKUPS: z.coerce.number().int().nonnegative().default(5),
LOG_LEVEL: z
.enum(["trace", "debug", "info", "warn", "error", "fatal", "mark", "off"])
.default("info"),
// ── Static file serving ──
STATIC_DIR: z.string().default("static"),

3
packages/logger/index.ts

@ -7,6 +7,7 @@ const logDir = path.resolve(process.cwd(), "logs");
const pathLog = path.resolve(logDir, "running.log");
const maxLogSize = env.LOG_MAX_SIZE;
const maxBackups = env.LOG_MAX_BACKUPS;
const logLevel = env.LOG_LEVEL;
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
@ -31,7 +32,7 @@ const configureLogger = () => {
categories: {
default: {
appenders: ["file", "console"],
level: "all",
level: logLevel,
}
},
};

38
server/api/_error-report.post.ts

@ -0,0 +1,38 @@
import log4js from "logger";
const logger = log4js.getLogger("ERROR");
interface ClientErrorReport {
message: string;
stack?: string;
url?: string;
line?: number;
column?: number;
userAgent?: string;
tags?: string[];
}
export default defineEventHandler(async (event) => {
const body = await readBody<ClientErrorReport>(event).catch(() => null);
if (!body || typeof body.message !== "string") {
return R.success({ ok: false });
}
const requestId = event.context.requestId ?? "(no-request-id)";
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
const tags = Array.isArray(body.tags) ? body.tags.join(",") : "client";
logger.error(
`[${requestId}]`,
`[CLIENT-ERROR]`,
`[${tags}]`,
`url=${body.url ?? "-"}`,
`ip=${ip}`,
`ua=${body.userAgent ?? "-"}`,
"\n",
body.message,
body.stack ?? "",
);
return R.success({ ok: true });
});

6
server/api/auth/login.post.ts

@ -5,6 +5,9 @@ import { setSessionCookie } from "#server/service/auth/cookie";
import { captchaConsume } from "#server/service/captcha/store";
import { assertLoginRegisterCaptchaFieldsPresent } from "#server/service/captcha/validate-body";
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit";
import log4js from "logger";
const logger = log4js.getLogger("AUTH");
export default defineWrappedResponseHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
@ -13,6 +16,7 @@ export default defineWrappedResponseHandler(async (event) => {
const body = await readBody(event);
assertLoginRegisterCaptchaFieldsPresent(body);
if (!captchaConsume(body.captchaId, body.captchaAnswer)) {
logger.warn("[LOGIN-FAIL] ip=%s username=%s — 验证码错误或已过期", ip, body?.username ?? "?");
throw createError({
statusCode: 400,
statusMessage: "验证码错误或已过期,请重试",
@ -25,10 +29,12 @@ export default defineWrappedResponseHandler(async (event) => {
password: body.password,
});
setSessionCookie(event, result.sessionId);
logger.info("[LOGIN-OK] ip=%s username=%s userId=%d", ip, body.username, result.user.id);
return R.success({
user: result.user,
});
} catch (err) {
logger.warn("[LOGIN-FAIL] ip=%s username=%s — %s", ip, body?.username ?? "?", err instanceof Error ? err.message : String(err));
throw toPublicAuthError(err);
}
});

5
server/api/auth/oauth/[provider]/authorize.get.ts

@ -1,12 +1,16 @@
import log4js from "logger";
import { oauthManager } from '#server/service/oauth/oauth-manager';
import { createAuthContext } from '#server/service/auth/context';
const logger = log4js.getLogger("OAUTH");
export default defineWrappedResponseHandler(async (event) => {
const { getCurrent } = createAuthContext(event);
const user = await getCurrent();
const providerName = getRouterParam(event, 'provider');
if (!providerName) {
logger.warn(`[authorize] Missing provider param`);
throw createError({
statusCode: 400,
statusMessage: 'Provider is required',
@ -14,6 +18,7 @@ export default defineWrappedResponseHandler(async (event) => {
}
const userId = user?.id;
logger.info(`[authorize] Generating auth URL: provider=${providerName}, userId=${userId ?? 'anonymous'}, isBinding=${!!userId}`);
const authUrl = oauthManager.getAuthorizationUrl(providerName, userId);
return sendRedirect(event, authUrl);

12
server/api/auth/oauth/[provider]/callback.get.ts

@ -1,15 +1,25 @@
import log4js from "logger";
import { oauthManager } from '#server/service/oauth/oauth-manager';
import { setSessionCookie } from '#server/service/auth/cookie';
import { OAuthError } from '#server/service/oauth/oauth-error';
import { FRONTEND_LOGIN_PATH } from "common/config"
const logger = log4js.getLogger("OAUTH");
export default defineWrappedResponseHandler(async (event) => {
const providerName = getRouterParam(event, 'provider');
const query = getQuery(event);
const { code, state } = query as { code?: string; state?: string };
logger.info(`[callback] Received callback: provider=${providerName}, hasCode=${!!code}, hasState=${!!state}`);
if (query.error) {
logger.warn(`[callback] Provider returned error: ${query.error}, description=${query.error_description ?? '-'}`);
}
if (!code || !state) {
logger.warn(`[callback] Missing params: code=${!!code}, state=${!!state}`);
return sendRedirect(event, `${FRONTEND_LOGIN_PATH}?oauth_error=missing_params`);
}
@ -20,9 +30,11 @@ export default defineWrappedResponseHandler(async (event) => {
setSessionCookie(event, result.sessionId);
}
logger.info(`[callback] Success: provider=${providerName}, isNewUser=${result.isNewUser}, hasSession=${!!result.sessionId}`);
return sendRedirect(event, `${FRONTEND_LOGIN_PATH}?oauth_success=1`);
} catch (error) {
const errorCode = error instanceof OAuthError ? error.code : 'OAUTH_UNKNOWN';
logger.error(`[callback] Failed: provider=${providerName}, errorCode=${errorCode}`, error);
return sendRedirect(event, `${FRONTEND_LOGIN_PATH}?oauth_error=${errorCode}`);
}
});

8
server/api/auth/register.post.ts

@ -5,6 +5,9 @@ import { captchaConsume } from "#server/service/captcha/store";
import { assertLoginRegisterCaptchaFieldsPresent } from "#server/service/captcha/validate-body";
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit";
import { getConfigGlobal } from "#server/utils/context";
import log4js from "logger";
const logger = log4js.getLogger("AUTH");
export default defineWrappedResponseHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
@ -13,6 +16,7 @@ export default defineWrappedResponseHandler(async (event) => {
const body = await readBody(event);
assertLoginRegisterCaptchaFieldsPresent(body);
if (!captchaConsume(body.captchaId, body.captchaAnswer)) {
logger.warn("[REGISTER-FAIL] ip=%s username=%s — 验证码错误或已过期", ip, body?.username ?? "?");
throw createError({
statusCode: 400,
statusMessage: "验证码错误或已过期,请重试",
@ -21,6 +25,7 @@ export default defineWrappedResponseHandler(async (event) => {
const allowRegister = await getConfigGlobal("allowRegister");
if (!allowRegister) {
logger.warn("[REGISTER-FAIL] ip=%s username=%s — 注册已关闭", ip, body?.username ?? "?");
throw createError({
statusCode: 403,
statusMessage: "当前已关闭注册",
@ -32,16 +37,19 @@ export default defineWrappedResponseHandler(async (event) => {
username: body.username,
password: body.password,
});
logger.info("[REGISTER-OK] ip=%s username=%s userId=%d", ip, body.username, user.id);
return R.success({
user,
});
} catch (err) {
if (err instanceof AuthConflictError) {
logger.warn("[REGISTER-FAIL] ip=%s username=%s — 用户名冲突", ip, body?.username ?? "?");
throw createError({
statusCode: 409,
statusMessage: "注册未成功,请调整用户名或稍后再试",
});
}
logger.warn("[REGISTER-FAIL] ip=%s username=%s — %s", ip, body?.username ?? "?", err instanceof Error ? err.message : String(err));
throw toPublicAuthError(err);
}
});

7
server/api/file/upload.post.ts

@ -3,6 +3,9 @@ import fs from 'node:fs';
import path from 'node:path';
import { callNodeListener } from 'h3';
import { RELATIVE_ASSETS_DIR, POST_MEDIA_PUBLIC_PREFIX } from '../../constants/upload';
import log4js from "logger";
const logger = log4js.getLogger("APP");
// 类型定义
interface IFile {
@ -75,10 +78,12 @@ export default defineWrappedResponseHandler(async (event) => {
size: file.size,
}));
logger.info("[%s] [FILE-UPLOAD] files=%d", event.context.requestId ?? "-", result.length);
return R.success(result);
} catch (err: any) {
console.error('上传失败:', err);
logger.error("[%s] [FILE-UPLOAD] 上传失败: %s", event.context.requestId ?? "-", err?.message ?? String(err));
return createError({
statusCode: 400,
statusMessage: err.message || '上传失败',

13
server/api/llm/chat/index.post.ts

@ -2,6 +2,9 @@ import { requireUser } from "#server/utils/context";
import { getProviderById, getModelById } from "#server/service/llm";
import { createOpenAI } from "@ai-sdk/openai";
import { streamText } from "ai";
import log4js from "logger";
const logger = log4js.getLogger("APP");
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
@ -40,6 +43,16 @@ export default defineEventHandler(async (event) => {
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
logger.info(
"[%s] [LLM-CHAT] userId=%d modelId=%d provider=%s messages=%d thinking=%s",
event.context.requestId ?? "-",
user.id,
llmModelId,
provider.name,
messages.length,
enableThinking ? "on" : "off",
);
const openai = createOpenAI({
apiKey: provider.apiKey,
baseURL: baseUrl,

5
server/middleware/10.auth-guard.ts

@ -3,6 +3,9 @@ import { isAllowlistedApiPath, isFrontendPageAllowed } from "#server/utils/auth-
import { getCurrentUser } from "#server/utils/context";
import { FRONTEND_LOGIN_PATH, FRONTEND_PAGE_GUEST_ONLY } from "common/config"
import { normalizePath } from "common/utils/path";
import log4js from "logger";
const logger = log4js.getLogger("AUTH");
export default eventHandler(async (event) => {
const path = event.path;
@ -22,6 +25,7 @@ export default eventHandler(async (event) => {
return;
}
logger.warn("[AUTH-GUARD] 401 未登录访问 API: %s %s", event.method, path);
throw createError({
statusCode: 401,
statusMessage: UNAUTHORIZED_MESSAGE,
@ -37,6 +41,7 @@ export default eventHandler(async (event) => {
const user = await getCurrentUser(event);
if (!user) {
// 未登录且页面不在白名单,重定向到登录页
logger.info("[AUTH-GUARD] 302 未登录重定向: %s -> %s", path, FRONTEND_LOGIN_PATH);
return sendRedirect(event, FRONTEND_LOGIN_PATH, 302);
}

7
server/plugins/00.req-time.ts

@ -18,6 +18,12 @@ declare module "http" {
}
}
declare module "h3" {
interface H3EventContext {
requestId: string;
}
}
function isStaticGet(event: any): boolean {
if (!event.path.startsWith(STATIC_PUBLIC_PREFIX)) return false;
const method = event.node.req.method;
@ -35,6 +41,7 @@ export default defineNitroPlugin((nitroApp) => {
event.node.req.$requestId = requestId;
event.node.req.$beginTime = new Date().getTime();
event.node.res.setHeader("X-Request-Id", requestId);
event.context.requestId = requestId;
if (isStaticGet(event)) return;

2
server/plugins/20.error-logger.ts

@ -49,8 +49,10 @@ export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("error", (error, context) => {
const event = context.event;
const requestId = event?.context?.requestId ?? "(no-request-id)";
const tags = Array.isArray(context.tags) ? (context.tags as string[]).join(",") : "";
const prefix = [
`[${requestId}]`,
event?.method ?? "",
event?.path ?? "(no request)",
tags ? `[${tags}]` : "",

40
server/scheduler/tasks/cleanup-logs.ts

@ -1,9 +1,43 @@
import log4js from "logger";
import fs from "node:fs";
import path from "node:path";
const logger = log4js.getLogger("SCHEDULER");
const LOG_DIR = path.resolve(process.cwd(), "logs");
export default async function cleanupLogs(payload?: Record<string, unknown>) {
const days = (payload?.days as number) ?? 30;
logger.info("Log cleanup placeholder: would remove logs older than %d days", days);
return { success: true, message: `Cleaned logs older than ${days} days` };
}
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
let deletedCount = 0;
try {
if (!fs.existsSync(LOG_DIR)) {
logger.info("Log cleanup: log dir does not exist, skipping");
return { success: true, message: "Log dir not found", deleted: 0 };
}
const files = fs.readdirSync(LOG_DIR);
for (const file of files) {
if (!file.endsWith(".log") && !file.endsWith(".log.gz")) continue;
const filePath = path.join(LOG_DIR, file);
try {
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.unlinkSync(filePath);
deletedCount++;
}
} catch {
// 单个文件删除失败不中断整体清理
}
}
logger.info("Log cleanup: removed %d files older than %d days", deletedCount, days);
return { success: true, message: `Cleaned ${deletedCount} log files older than ${days} days`, deleted: deletedCount };
} catch (err) {
logger.error("Log cleanup failed: %s", err instanceof Error ? err.message : String(err));
return { success: false, message: "Log cleanup failed", deleted: 0 };
}
}

4
server/service/auth/context.ts

@ -2,6 +2,9 @@ import type { H3Event } from "h3";
import { SESSION_COOKIE_NAME, UNAUTHORIZED_MESSAGE } from "#server/constants/auth";
import { getCurrentUser, type MinimalUser } from ".";
import { getSessionId } from "./cookie";
import log4js from "logger";
const logger = log4js.getLogger("AUTH");
export function createAuthContext(event: H3Event) {
let currentUserPromise: Promise<MinimalUser | null> | undefined;
@ -22,6 +25,7 @@ export function createAuthContext(event: H3Event) {
const requireUser = async () => {
const user = await getCurrent();
if (!user) {
logger.warn("[AUTH] 401 requireUser 拒绝: path=%s", event.path);
throw createError({
statusCode: 401,
statusMessage: UNAUTHORIZED_MESSAGE,

2
server/service/auth/index.ts

@ -227,11 +227,13 @@ export async function getCurrentUser(sessionId: string): Promise<MinimalUser | n
if (!row) {
await dbGlobal.delete(sessions).where(and(eq(sessions.id, sessionId), lte(sessions.expiresAt, now)));
logger.info("session cleaned (expired): %s", sessionId);
return null;
}
if (row.status !== "active") {
await dbGlobal.delete(sessions).where(eq(sessions.id, sessionId));
logger.warn("session cleaned (user not active): sessionId=%s userId=%d username=%s", sessionId, row.userId, row.username);
return null;
}

5
server/service/chat/index.ts

@ -10,6 +10,9 @@ import { dbGlobal } from "drizzle-pkg/lib/db"
import { chatMessages } from "drizzle-pkg/lib/schema/content"
import { desc, sql } from "drizzle-orm"
import { env } from "env"
import log4js from "logger";
const logger = log4js.getLogger("email");
// ── Types ──
export interface ChatMessage {
@ -191,7 +194,7 @@ export async function sendMessage(
subject: `[聊天室] @${mention} — 来自 ${displayNickname} 的消息`,
text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${env.APP_URL || '(未配置)'}`,
}).catch(err => {
console.error('Failed to send @mention email:', err)
logger.error('Failed to send @mention email: %s', err instanceof Error ? err.message : String(err))
})
msg.mentionNotified = true
break

21
server/service/oauth/oauth-manager.ts

@ -282,6 +282,8 @@ export class OAuthManager {
provider: OAuthProviderConfig,
code: string
): Promise<{ access_token?: string; token_type?: string; scope?: string }> {
logger.info(`[exchangeToken] Requesting token from ${provider.tokenUrl}, provider=${provider.name}`);
const response = await fetch(provider.tokenUrl, {
method: 'POST',
headers: {
@ -299,17 +301,27 @@ export class OAuthManager {
});
if (!response.ok) {
const errorBody = await response.text().catch(() => '<unreadable>');
logger.error(`[exchangeToken] Token endpoint returned ${response.status} ${response.statusText}, bodyLen=${errorBody.length}`);
throw new OAuthError(
OAuthErrorCodes.TOKEN_EXCHANGE_FAILED,
'Failed to exchange token'
);
}
return response.json() as Promise<{
const data = await (response.json() as Promise<{
access_token?: string;
token_type?: string;
scope?: string;
}>;
}>);
if (!data.access_token) {
logger.error(`[exchangeToken] Token endpoint returned 200 but no access_token, keys=${Object.keys(data).join(',')}`);
} else {
logger.info(`[exchangeToken] Token acquired: provider=${provider.name}, tokenType=${data.token_type ?? '-'}, scope=${data.scope ?? '-'}`);
}
return data;
}
private mapUserInfo(raw: Record<string, unknown>, mapping: FieldMapping) {
@ -331,6 +343,8 @@ export class OAuthManager {
provider: OAuthProviderConfig,
accessToken: string
): Promise<{ provider: string; providerUserId: string; username?: string; email?: string; avatar?: string } | null> {
logger.info(`[getUserInfo] Requesting user info from ${provider.userInfoUrl}, provider=${provider.name}`);
const response = await fetch(provider.userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
@ -339,10 +353,13 @@ export class OAuthManager {
});
if (!response.ok) {
const errorBody = await response.text().catch(() => '<unreadable>');
logger.error(`[getUserInfo] User info endpoint returned ${response.status} ${response.statusText}, body=${errorBody}`);
return null;
}
const raw = await response.json() as Record<string, unknown>;
logger.info(`[getUserInfo] User info retrieved: provider=${provider.name}, fields=${Object.keys(raw).join(',')}`);
return this.mapUserInfo(raw, provider.userInfoMapping);
}

9
server/utils/admin-guard.ts

@ -1,10 +1,19 @@
import type { H3Event } from "h3";
import type { MinimalUser } from "#server/service/auth";
import { requireUser } from "#server/utils/context";
import log4js from "logger";
const logger = log4js.getLogger("AUTH");
export async function requireAdmin(event: H3Event): Promise<MinimalUser> {
const user = await requireUser(event);
if (user.role !== "admin") {
logger.warn(
"[ADMIN-DENIED] userId=%d username=%s path=%s — 非管理员访问被拒绝",
user.id,
user.username,
event.path,
);
throw createError({
statusCode: 403,
statusMessage: "需要管理员权限",

2
server/utils/handler.ts

@ -25,7 +25,9 @@ export const defineWrappedResponseHandler = <T extends EventHandlerRequest, D>(
const response = await handler(event)
return response
} catch (error) {
const requestId = event.context.requestId ?? "(no-request-id)";
logger.error(
`[${requestId}]`,
event?.method ?? "",
event?.path ?? "(no request)",
`[request]`,

5
server/utils/simple-rate-limit.ts

@ -1,3 +1,7 @@
import log4js from "logger";
const logger = log4js.getLogger("APP");
type Window = { count: number; resetAt: number };
const windows = new Map<string, Window>();
@ -11,6 +15,7 @@ export function assertUnderRateLimit(key: string, max: number, windowMs: number)
}
w.count += 1;
if (w.count > max) {
logger.warn("[RATE-LIMIT] key=%s count=%d max=%d — 请求被限流", key, w.count, max);
throw createError({
statusCode: 429,
statusMessage: "请求过于频繁,请稍后再试",

Loading…
Cancel
Save