diff --git a/.env.example b/.env.example index 67b4e98..6529a7c 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ -DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres \ No newline at end of file +DATABASE_URL=file:./db.sqlite +STATIC_DIR=static +UPLOAD_SUBDIR=upload +NITRO_PORT=3399 \ No newline at end of file diff --git a/build-files/migrate/migrate-pg.js b/build-files/migrate/pg.js similarity index 100% rename from build-files/migrate/migrate-pg.js rename to build-files/migrate/pg.js diff --git a/build-files/migrate/migrate-sqlite.js b/build-files/migrate/sqlite3.js similarity index 100% rename from build-files/migrate/migrate-sqlite.js rename to build-files/migrate/sqlite3.js diff --git a/build-files/run.sh b/build-files/run.sh index 6dda8a2..f5f29ec 100644 --- a/build-files/run.sh +++ b/build-files/run.sh @@ -4,5 +4,6 @@ if [ -f .env ]; then export $(grep -v '^#' .env | xargs) fi -node server/migrate-pg.js migrations +node server/migrate/sqlite3.js migrations +node server/seed/sqlite3.js node server/index.mjs \ No newline at end of file diff --git a/build-files/seed/sqlite3.js b/build-files/seed/sqlite3.js new file mode 100644 index 0000000..51d2714 --- /dev/null +++ b/build-files/seed/sqlite3.js @@ -0,0 +1,117 @@ +/** + * 部署后首次启动前执行:若库中尚无 admin,则用环境变量创建首个管理员。 + * 逻辑对齐 `packages/drizzle-pkg/seed.ts`,使用 SQLite(与 `migrate-sqlite.js` 相同)。 + * + * 环境变量(与 seed.ts 一致): + * - DATABASE_URL:SQLite 路径,可为 `file:/path/to/db.sqlite` 或裸路径 + * - BOOTSTRAP_ADMIN_USERNAME / BOOTSTRAP_ADMIN_PASSWORD:可选;未设置或校验失败则跳过 + */ +import Database from 'better-sqlite3' +import { hash } from 'bcryptjs' +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** 与 `server/service/auth/index.ts` 一致 */ +const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/ +const MIN_PASSWORD_LENGTH = 6 + +/** 与 seed.ts 一致:仅当小写用户名本身符合 slug 规则时写入 public_slug */ +const PUBLIC_SLUG_REGEX = /^[a-z0-9]{3,20}$/ + +function derivePublicSlug(username) { + const lower = username.toLowerCase() + return PUBLIC_SLUG_REGEX.test(lower) ? lower : null +} + +/** + * 与 `migrate-sqlite.js` 一致:相对路径相对 `process.cwd()`(例如 `.output/run.sh` 下与迁移写入同一文件)。 + * Nitro 内 `resolveSqliteDatabaseUrl` 在无法锚定 drizzle-pkg 时也会回退到 cwd,避免「迁移/seed 成功、服务 CANTOPEN」。 + */ +function resolveSqliteFilePath(dbUrl) { + const stripped = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl + if (!stripped) { + throw new Error('DATABASE_URL 未设置,且未提供有效的 SQLite 文件路径') + } + if (path.isAbsolute(stripped)) { + return stripped + } + return path.resolve(process.cwd(), stripped) +} + +function openSqlite() { + const dbUrl = process.env.DATABASE_URL || '' + const sqlitePath = resolveSqliteFilePath(dbUrl) + mkdirSync(path.dirname(sqlitePath), { recursive: true }) + return new Database(sqlitePath) +} + +async function main() { + const db = openSqlite() + try { + const existingAdmin = db + .prepare(`SELECT id FROM users WHERE role = ? LIMIT 1`) + .get('admin') + if (existingAdmin) { + console.log('Bootstrap skipped: admin exists') + return + } + + const username = process.env.BOOTSTRAP_ADMIN_USERNAME + const password = process.env.BOOTSTRAP_ADMIN_PASSWORD + + if (!username || !password) { + console.warn( + 'Bootstrap skipped: set BOOTSTRAP_ADMIN_USERNAME and BOOTSTRAP_ADMIN_PASSWORD', + ) + return + } + + if (!USERNAME_REGEX.test(username) || password.length < MIN_PASSWORD_LENGTH) { + console.warn( + 'Bootstrap skipped: invalid BOOTSTRAP_ADMIN_USERNAME or BOOTSTRAP_ADMIN_PASSWORD', + ) + return + } + + const passwordHash = await hash(password, 10) + const { maxId } = db.prepare(`SELECT COALESCE(MAX(id), 0) AS maxId FROM users`).get() + const userId = (maxId ?? 0) + 1 + const publicSlug = derivePublicSlug(username) + + try { + db.prepare( + ` + INSERT INTO users (id, username, password, role, status, public_slug) + VALUES (@id, @username, @password, @role, @status, @public_slug) + `, + ).run({ + id: userId, + username, + password: passwordHash, + role: 'admin', + status: 'active', + public_slug: publicSlug, + }) + console.log('Bootstrap complete: admin user created') + } catch (err) { + console.warn( + 'Bootstrap skipped: could not insert admin (unique conflict or DB error)', + err, + ) + } + } finally { + db.close() + } +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + +if (isMain) { + // 成功时自然退出(退出码 0);仅未捕获错误时 process.exit(1)。 + main().catch((e) => { + console.error(e) + process.exit(1) + }) +} diff --git a/bun.lock b/bun.lock index 2e8960f..e7dec4e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "person-panel", "dependencies": { - "@nuxt/ui": "^4.6.1", - "better-sqlite3": "^12.9.0", + "@nuxt/ui": "4.6.1", + "better-sqlite3": "12.9.0", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", "drizzle-pkg": "workspace:*", @@ -18,14 +18,14 @@ "multer": "2.1.1", "nuxt": "4.4.2", "pg": "8.20.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "4.2.2", "ufo": "1.6.3", "vue": "3.5.32", "vue-router": "5.0.4", "zod": "4.3.6", }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", + "@types/better-sqlite3": "7.6.13", "@types/multer": "2.1.0", "@types/pg": "8.20.0", "drizzle-kit": "0.31.10", diff --git a/package.json b/package.json index eb0778a..85b200f 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,19 @@ "scripts": { "build": "nuxt build && bun run cp:db && bun --elide-lines=0 --filter drizzle-pkg build", "dev": "nuxt dev", - "cp:db": "cp build-files/run.sh .output/run.sh && cp .env.example .output/.env && cp -r build-files/migrate/* .output/server/", + "deploy": "bash scripts/deploy-gitea.sh", + "cp:db": "bash scripts/mv.sh", "migrate:test": "sh scripts/migrate-test.sh", "db:migrate": "bun --elide-lines=0 --filter drizzle-pkg migrate", - "db:generate": "bun --elide-lines=0 --filter drizzle-pkg generate --name", + "db:generate": "bun --elide-lines=0 --filter drizzle-pkg generate", "db:seed": "bun --elide-lines=0 --filter drizzle-pkg seed", "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare" }, "dependencies": { - "@nuxt/ui": "^4.6.1", - "better-sqlite3": "^12.9.0", + "@nuxt/ui": "4.6.1", + "better-sqlite3": "12.9.0", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", "drizzle-pkg": "workspace:*", @@ -32,14 +33,14 @@ "multer": "2.1.1", "nuxt": "4.4.2", "pg": "8.20.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "4.2.2", "ufo": "1.6.3", "vue": "3.5.32", "vue-router": "5.0.4", "zod": "4.3.6" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", + "@types/better-sqlite3": "7.6.13", "@types/multer": "2.1.0", "@types/pg": "8.20.0", "drizzle-kit": "0.31.10", diff --git a/packages/drizzle-pkg/database/pg/db.ts b/packages/drizzle-pkg/database/pg/db.ts deleted file mode 100644 index b52f3e6..0000000 --- a/packages/drizzle-pkg/database/pg/db.ts +++ /dev/null @@ -1,11 +0,0 @@ - -import { drizzle } from "drizzle-orm/node-postgres"; - -if (process.env.NODE_ENV === 'production') { - // 打包时需要保证migrator被引入 - import('drizzle-orm/node-postgres/migrator') -} - -const _db = drizzle(process.env.DATABASE_URL!); - -export { _db as dbGlobal } \ No newline at end of file diff --git a/packages/drizzle-pkg/database/pg/schema/auth.ts b/packages/drizzle-pkg/database/pg/schema/auth.ts deleted file mode 100644 index db1b2e9..0000000 --- a/packages/drizzle-pkg/database/pg/schema/auth.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { sql } from "drizzle-orm"; -import { integer, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; - -export const users = pgTable("users", { - id: integer().primaryKey(), - username: varchar().notNull().unique(), - email: varchar(), - nickname: varchar(), - password: varchar().notNull(), - avatar: varchar(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at') - .defaultNow() - .$onUpdate(() => sql`CURRENT_TIMESTAMP`) - .notNull(), -}); \ No newline at end of file diff --git a/packages/drizzle-pkg/database/sqlite/db.ts b/packages/drizzle-pkg/database/sqlite/db.ts deleted file mode 100644 index 375f1f2..0000000 --- a/packages/drizzle-pkg/database/sqlite/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drizzle } from 'drizzle-orm/libsql'; - -if (process.env.NODE_ENV === 'production') { - // 打包时需要保证migrator被引入 - import('drizzle-orm/better-sqlite3/migrator') -} - -const _db = drizzle(process.env.DATABASE_URL!); - -export { _db as dbGlobal } \ No newline at end of file diff --git a/packages/drizzle-pkg/env.ts b/packages/drizzle-pkg/env.ts index 92a311f..fcbc531 100644 --- a/packages/drizzle-pkg/env.ts +++ b/packages/drizzle-pkg/env.ts @@ -2,3 +2,4 @@ import { config } from 'dotenv'; config({ path: '../../.env' }); +console.log(process.env); diff --git a/packages/drizzle-pkg/lib/db.ts b/packages/drizzle-pkg/lib/db.ts index dda9669..5456678 100644 --- a/packages/drizzle-pkg/lib/db.ts +++ b/packages/drizzle-pkg/lib/db.ts @@ -1 +1,23 @@ -export { dbGlobal } from '../database/pg/db' \ No newline at end of file +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { resolveSqliteDatabaseUrl } from "./resolve-sqlite-url"; + +if (process.env.NODE_ENV === "production") { + // 打包时需要保证migrator被引入 + import("drizzle-orm/better-sqlite3/migrator"); +} + +const rawUrl = process.env.DATABASE_URL; +if (!rawUrl) { + throw new Error("DATABASE_URL 未设置"); +} +const resolvedUrl = resolveSqliteDatabaseUrl(rawUrl); +process.env.DATABASE_URL = resolvedUrl; + +// better-sqlite3 需要裸文件路径;`file:` 前缀仍保留在 DATABASE_URL 供 drizzle-kit 等使用 +const sqlitePath = resolvedUrl.startsWith("file:") + ? resolvedUrl.slice("file:".length) + : resolvedUrl; + +const _db = drizzle(sqlitePath); + +export { _db as dbGlobal }; diff --git a/packages/drizzle-pkg/lib/paths.ts b/packages/drizzle-pkg/lib/paths.ts new file mode 100644 index 0000000..a91277a --- /dev/null +++ b/packages/drizzle-pkg/lib/paths.ts @@ -0,0 +1,33 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function isDrizzlePkgRoot(dir: string): boolean { + const pkg = path.join(dir, "package.json"); + if (!existsSync(pkg)) { + return false; + } + try { + const { name } = JSON.parse(readFileSync(pkg, "utf8")) as { name?: string }; + return name === "drizzle-pkg"; + } catch { + return false; + } +} + +/** + * `drizzle-pkg` 包根目录(与 `package.json`、`db.sqlite` 同级)。 + * - 开发:用 `import.meta` 锚定,避免 cwd 变化把 `file:db.sqlite` 指到错误文件(只读 / DBMOVED)。 + * - 生产:打包后 chunk 路径不可靠,回退到 `cwd/packages/drizzle-pkg`;再不行则回退 `process.cwd()`(与 `.output` 下 migrate/seed 的相对 `DATABASE_URL` 一致)。 + */ +export function getDrizzlePkgRoot(): string { + const fromMeta = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + if (isDrizzlePkgRoot(fromMeta)) { + return fromMeta; + } + const fromCwd = path.resolve(process.cwd(), "packages/drizzle-pkg"); + if (isDrizzlePkgRoot(fromCwd)) { + return fromCwd; + } + return process.cwd(); +} diff --git a/packages/drizzle-pkg/lib/resolve-sqlite-url.ts b/packages/drizzle-pkg/lib/resolve-sqlite-url.ts new file mode 100644 index 0000000..de59c62 --- /dev/null +++ b/packages/drizzle-pkg/lib/resolve-sqlite-url.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { getDrizzlePkgRoot } from "./paths"; + +/** 将 `file:` 相对路径解析为绝对路径(相对 drizzle-pkg 根目录) */ +export function resolveSqliteDatabaseUrl(url: string): string { + if (!url.startsWith("file:")) { + return url; + } + let filePath = url.slice("file:".length); + if (path.isAbsolute(filePath)) { + return `file:${filePath}`; + } + const root = getDrizzlePkgRoot(); + return `file:${path.resolve(root, filePath)}`; +} diff --git a/packages/drizzle-pkg/lib/schema/auth.ts b/packages/drizzle-pkg/lib/schema/auth.ts index a342588..b031189 100644 --- a/packages/drizzle-pkg/lib/schema/auth.ts +++ b/packages/drizzle-pkg/lib/schema/auth.ts @@ -1 +1,25 @@ -export { users } from '../../database/pg/schema/auth' \ No newline at end of file +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: integer().primaryKey(), + username: text().notNull().unique(), + email: text(), + nickname: text(), + password: text().notNull(), + avatar: text(), + role: text().notNull().default("user"), + status: text().notNull().default("active"), + publicSlug: text("public_slug").unique(), + bioMarkdown: text("bio_markdown"), + bioVisibility: text("bio_visibility").notNull().default("private"), + socialLinksJson: text("social_links_json").notNull().default("[]"), + avatarVisibility: text("avatar_visibility").notNull().default("private"), + discoverVisible: integer("discover_visible", { mode: "boolean" }).notNull().default(true), + discoverLocation: text("discover_location"), + discoverShowLocation: integer("discover_show_location", { mode: "boolean" }).notNull().default(false), + createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); diff --git a/packages/drizzle-pkg/seed.ts b/packages/drizzle-pkg/seed.ts index 6ed7f28..47ae7d3 100644 --- a/packages/drizzle-pkg/seed.ts +++ b/packages/drizzle-pkg/seed.ts @@ -1,11 +1,11 @@ import './env'; import { seed } from "drizzle-seed"; -import { usersTable } from "./lib/schema/auth"; +import { users } from "./lib/schema/auth"; import { dbGlobal } from "./lib/db"; async function main() { - await seed(dbGlobal, { usersTable }).refine((f) => ({ - usersTable: { + await seed(dbGlobal, { users }).refine((f) => ({ + users: { columns: { name: f.fullName(), age: f.int({ minValue: 18, maxValue: 60 }), @@ -22,4 +22,3 @@ main().catch(e => { console.error(e); process.exit(1); }); - diff --git a/scripts/deploy-gitea.sh b/scripts/deploy-gitea.sh new file mode 100644 index 0000000..65dd28d --- /dev/null +++ b/scripts/deploy-gitea.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env sh + +# 配置区(只改这里) +GIT_REPO_URL="ssh://root@git.xieyaxin.top:8892/topuser/nuxt4-demo.git" +PROD_BRANCH="deploy" # 你要存放产物的分支名(会自动创建) +BUILD_FOLDER=".output" # 打包产物目录 +ARCHIVE_NAME="build-output.tar.gz" +ARCHIVE_PATH=".tmp_${ARCHIVE_NAME}" +COMMIT_MSG="deploy: build at $(date +'%Y-%m-%d %H:%M:%S')" + +# 1. 先打包 +echo "📦 构建项目..." +bun run build + +# 2. 克隆远程产物分支到临时目录 +echo "⬇️ 拉取产物分支..." +git clone --single-branch --branch $PROD_BRANCH $GIT_REPO_URL .tmp_dist || { + echo "🆕 分支不存在,创建新分支..." + mkdir .tmp_dist + cd .tmp_dist + git init + git checkout -b $PROD_BRANCH + git remote add origin $GIT_REPO_URL + cd .. +} + +# 3. 压缩构建产物 +echo "🗜️ 压缩构建目录..." +tar -czf "$ARCHIVE_PATH" -C "$BUILD_FOLDER" . + +# 4. 删除旧产物,复制新产物 +echo "♻️ 更新产物文件..." +rm -rf .tmp_dist/* +cp -r $BUILD_FOLDER/.drone.yml .tmp_dist/.drone.yml +cp "$ARCHIVE_PATH" ".tmp_dist/$ARCHIVE_NAME" + +# 5. 提交并推送 +cd .tmp_dist +git add -A +git commit -m "$COMMIT_MSG" + +echo "🚀 推送到远程分支 $PROD_BRANCH..." +git push origin $PROD_BRANCH + +# 6. 清理临时文件 +cd .. +rm -rf .tmp_dist +rm -f "$ARCHIVE_PATH" + +echo "✅ 发布完成!" \ No newline at end of file diff --git a/scripts/deploy-gitee.sh b/scripts/deploy-gitee.sh new file mode 100644 index 0000000..a5f0055 --- /dev/null +++ b/scripts/deploy-gitee.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env sh + +# 配置区(只改这里) +GIT_REPO_URL="git@gitee.com:xieyaxin/nuxt4-demo.git" +PROD_BRANCH="deploy" # 你要存放产物的分支名(会自动创建) +BUILD_FOLDER=".output" # 打包产物目录 +ARCHIVE_NAME="build-output.tar.gz" +ARCHIVE_PATH=".tmp_${ARCHIVE_NAME}" +COMMIT_MSG="deploy: build at $(date +'%Y-%m-%d %H:%M:%S')" + +# 1. 先打包 +echo "📦 构建项目..." +bun run build + +# 2. 克隆远程产物分支到临时目录 +echo "⬇️ 拉取产物分支..." +git clone --single-branch --branch $PROD_BRANCH $GIT_REPO_URL .tmp_dist || { + echo "🆕 分支不存在,创建新分支..." + mkdir .tmp_dist + cd .tmp_dist + git init + git checkout -b $PROD_BRANCH + git remote add origin $GIT_REPO_URL + cd .. +} + +# 3. 压缩构建产物 +echo "🗜️ 压缩构建目录..." +tar -czf "$ARCHIVE_PATH" -C "$BUILD_FOLDER" . + +# 4. 删除旧产物,复制新产物 +echo "♻️ 更新产物文件..." +rm -rf .tmp_dist/* +cp -r $BUILD_FOLDER/.drone.yml .tmp_dist/.drone.yml +cp "$ARCHIVE_PATH" ".tmp_dist/$ARCHIVE_NAME" + +# 5. 提交并推送 +cd .tmp_dist +git add -A +git commit -m "$COMMIT_MSG" + +echo "🚀 推送到远程分支 $PROD_BRANCH..." +git push origin $PROD_BRANCH + +# 6. 清理临时文件 +cd .. +rm -rf .tmp_dist +rm -f "$ARCHIVE_PATH" + +echo "✅ 发布完成!" \ No newline at end of file diff --git a/scripts/migrate-test.sh b/scripts/migrate-test.sh index f2a4a7a..079adc4 100644 --- a/scripts/migrate-test.sh +++ b/scripts/migrate-test.sh @@ -4,4 +4,4 @@ fi echo "DATABASE_URL: $DATABASE_URL" -node build-files/migrate/migrate-pg.js packages/drizzle-pkg/migrations +node build-files/migrate/sqlite3.js packages/drizzle-pkg/migrations diff --git a/scripts/mv.sh b/scripts/mv.sh new file mode 100644 index 0000000..3d499c4 --- /dev/null +++ b/scripts/mv.sh @@ -0,0 +1,11 @@ +if [ -f .env.prod ]; then + cp .env.prod .output/.env +else + cp .env.example .output/.env +fi + +cp build-files/run.sh .output/run.sh +mkdir .output/server/migrate +cp -r build-files/migrate/* .output/server/migrate/ +mkdir .output/server/seed +cp -r build-files/* .output/server/seed/ \ No newline at end of file diff --git a/server/constants/cloud-probes.ts b/server/constants/cloud-probes.ts new file mode 100644 index 0000000..719f08c --- /dev/null +++ b/server/constants/cloud-probes.ts @@ -0,0 +1,42 @@ +/** + * 各云平台 / 容器编排常见 HTTP 探针路径(不含 `/`,避免误伤站点根)。 + * 可按负载均衡控制台实际配置增删;若与业务路由同名请从列表中移除对应项。 + */ +export const CLOUD_PROBE_PATHS = [ + // 通用 / Kubernetes + "/health", + "/healthz", + "/livez", + "/readyz", + "/liveness", + "/readiness", + "/startup", + "/health/live", + "/health/ready", + "/health/startup", + // Spring Actuator(经网关暴露时偶见) + "/actuator/health", + "/actuator/health/liveness", + "/actuator/health/readiness", + // Azure App Service 相关默认探测 + "/robots933456.txt", + // 国内云控制台常见示例静态页 + "/check.html", + "/status.html", + "/ping", + "/status", + "/alive", +] as const; + +/** 阿里云等:健康检查为 `/rpc` 或带后缀路径(如控制台填写的 `/rpc/...`),按前缀匹配 */ +export const CLOUD_PROBE_PATH_PREFIXES = ["/rpc"] as const; + +export const CLOUD_PROBE_PATH_SET = new Set(CLOUD_PROBE_PATHS); + +export function isCloudProbePath(pathname: string): boolean { + if (CLOUD_PROBE_PATH_SET.has(pathname)) return true; + for (const prefix of CLOUD_PROBE_PATH_PREFIXES) { + if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return true; + } + return false; +} diff --git a/server/constants/upload.ts b/server/constants/upload.ts new file mode 100644 index 0000000..6fee1b9 --- /dev/null +++ b/server/constants/upload.ts @@ -0,0 +1,56 @@ +import path from "node:path"; + +function trimSlashes(input: string): string { + return input.trim().replace(/^\/+|\/+$/g, ""); +} + +function hasParentSegment(input: string): boolean { + return input + .split("/") + .map((part) => part.trim()) + .some((part) => part === ".."); +} + +/** 允许相对项目根或绝对路径;禁止含 `..` 的路径片段(防止配置逃逸)。 */ +function ensureConfigurableDir(input: string, fallback: string, envName: string): string { + const raw = input.trim(); + if (!raw) { + return fallback; + } + const normalized = path.normalize(raw); + const segments = normalized.split(path.sep); + if (segments.some((part) => part === "..")) { + throw new Error(`${envName} must not contain ".." path segments`); + } + return normalized; +} + +function ensureSafeSubdir(input: string, fallback: string, envName: string): string { + const value = trimSlashes(input); + if (!value) { + return fallback; + } + if (hasParentSegment(value)) { + throw new Error(`${envName} must not contain ".." segments`); + } + return value; +} + +/** 静态资源 URL 前缀固定为 `/static`,不允许通过环境变量覆写。 */ +export const STATIC_PUBLIC_PREFIX = "/static"; + +/** 静态资源根目录(相对项目根或绝对路径),默认 `static` */ +export const STATIC_DIR = ensureConfigurableDir(process.env.STATIC_DIR ?? "static", "static", "STATIC_DIR"); + +/** 媒体上传子目录(相对 STATIC_DIR),默认 `upload` */ +export const UPLOAD_SUBDIR = ensureSafeSubdir( + process.env.UPLOAD_SUBDIR ?? "upload", + "upload", + "UPLOAD_SUBDIR", +); + +/** 媒体上传目录(与 STATIC_DIR 同为相对或绝对),默认 `static/upload` */ +export const RELATIVE_ASSETS_DIR = path.join(STATIC_DIR, UPLOAD_SUBDIR); + +/** 与 `media` 返回及静态路径一致,无前导 host */ +export const POST_MEDIA_PUBLIC_PREFIX = `${STATIC_PUBLIC_PREFIX}/${UPLOAD_SUBDIR}/`; diff --git a/server/middleware/00.cloud-probe.ts b/server/middleware/00.cloud-probe.ts new file mode 100644 index 0000000..4cacf1c --- /dev/null +++ b/server/middleware/00.cloud-probe.ts @@ -0,0 +1,25 @@ +import { getRequestURL } from "h3"; +import { isCloudProbePath } from "#server/constants/cloud-probes"; + +const METHODS = new Set(["GET", "HEAD"]); + +export default eventHandler((event) => { + if (!METHODS.has(event.method)) return; + + const pathname = getRequestURL(event).pathname; + if (!isCloudProbePath(pathname)) return; + + setHeader(event, "content-type", "text/plain; charset=utf-8"); + setHeader( + event, + "cache-control", + "no-store, no-cache, must-revalidate, proxy-revalidate", + ); + + if (event.method === "HEAD") { + setResponseStatus(event, 200); + return; + } + + return "OK"; +}); diff --git a/server/middleware/00.public.ts b/server/middleware/01.public.ts similarity index 82% rename from server/middleware/00.public.ts rename to server/middleware/01.public.ts index 88a6eb4..58a7d06 100644 --- a/server/middleware/00.public.ts +++ b/server/middleware/01.public.ts @@ -1,4 +1,4 @@ -import { resolve, join, sep, extname } from "node:path"; +import { resolve, join, relative } from "node:path"; import { promises as fsp } from "node:fs"; import { decodePath, @@ -8,9 +8,10 @@ import { } from "ufo"; import type { H3Event } from "h3"; import mime from "mime"; +import { STATIC_DIR, STATIC_PUBLIC_PREFIX } from "#server/constants/upload"; const METHODS = new Set(["HEAD", "GET"]); -const SAFE_BASE_DIR = resolve("public"); +const SAFE_BASE_DIR = resolve(STATIC_DIR); // 缓存配置 const CACHE_CONTROL = "public, max-age=31536000, immutable"; @@ -20,7 +21,7 @@ const NOT_FOUND = 404; const SERVER_ERROR = 500; export default eventHandler(async (event: H3Event) => { - if (!event.path.startsWith("/public")) return; + if (!event.path.startsWith(STATIC_PUBLIC_PREFIX)) return; const { req, res } = event.node; const method = req.method; @@ -29,16 +30,18 @@ export default eventHandler(async (event: H3Event) => { try { // 安全解析路径 - const url = event.path.replace(/^\/public/, ""); + const url = event.path.replace(STATIC_PUBLIC_PREFIX, ""); + const pathname = decodePath( withLeadingSlash(withoutTrailingSlash(parseURL(url).pathname)) ); - + const targetPath = join(SAFE_BASE_DIR, pathname); const resolvedPath = resolve(targetPath); - // 安全校验 - if (!resolvedPath.startsWith(SAFE_BASE_DIR + sep)) { + // 安全校验(支持 STATIC_DIR 为绝对路径;relative 在越界时为 `..` 开头) + const rel = relative(SAFE_BASE_DIR, resolvedPath); + if (rel.startsWith("..") || rel === "..") { res.statusCode = FORBIDDEN; return "Forbidden"; } diff --git a/server/plugins/00.global.ts b/server/plugins/00.global.ts deleted file mode 100644 index 220db0d..0000000 --- a/server/plugins/00.global.ts +++ /dev/null @@ -1,8 +0,0 @@ - -if (import.meta.dev) { - console.log("plugin: 00.global"); -} - -export default defineNitroPlugin(async () => { - -}) diff --git a/server/plugins/01.req-time.ts b/server/plugins/00.req-time.ts similarity index 96% rename from server/plugins/01.req-time.ts rename to server/plugins/00.req-time.ts index 81a9819..d201118 100644 --- a/server/plugins/01.req-time.ts +++ b/server/plugins/00.req-time.ts @@ -4,7 +4,7 @@ import { randomUUID } from "crypto"; const logger = log4js.getLogger("APP") if (import.meta.dev) { - console.log("plugin: 1.error-handler"); + console.log("plugin: 00.req-time"); } declare module "http" { diff --git a/server/plugins/02.well-known-ignore.ts b/server/plugins/01.well-known-ignore.ts similarity index 100% rename from server/plugins/02.well-known-ignore.ts rename to server/plugins/01.well-known-ignore.ts diff --git a/server/plugins/03.error-logger.ts b/server/plugins/02.error-logger.ts similarity index 50% rename from server/plugins/03.error-logger.ts rename to server/plugins/02.error-logger.ts index c0385fe..bb1bc87 100644 --- a/server/plugins/03.error-logger.ts +++ b/server/plugins/02.error-logger.ts @@ -2,6 +2,23 @@ import log4js from "logger"; const logger = log4js.getLogger("ERROR"); +/** 路径/路由类「找不到」:只打控制台,不落盘(避免扫描、错 URL 等刷爆 running.log) */ +function isPathNotFoundLikeError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const e = error as NodeJS.ErrnoException & { statusCode?: number; statusMessage?: string }; + const code = typeof e.code === "string" ? e.code : ""; + if (code === "ENOENT" || code === "ENOTDIR") return true; + + if (e.statusCode !== 404) return false; + const sm = (e.statusMessage ?? "").trim(); + if (!sm) return true; + if (/^not found$/i.test(sm)) return true; + if (/page not found/i.test(sm)) return true; + if (/^cannot (get|post|put|delete|patch|head)\b/i.test(sm)) return true; + if (/no route found|cannot find.*route|static asset.*not found/i.test(sm)) return true; + return false; +} + const processHandlersKey = "__personPanelErrorLoggerProcessHandlers"; function installProcessErrorLogging() { @@ -23,7 +40,7 @@ function installProcessErrorLogging() { } if (import.meta.dev) { - console.log("plugin: 03.error-logger"); + console.log("plugin: 02.error-logger"); } export default defineNitroPlugin((nitroApp) => { @@ -32,12 +49,19 @@ export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook("error", (error, context) => { const event = context.event; const tags = Array.isArray(context.tags) ? (context.tags as string[]).join(",") : ""; - logger.error( + const prefix = [ event?.method ?? "", event?.path ?? "(no request)", tags ? `[${tags}]` : "", - "\n", - error - ); + ] + .filter(Boolean) + .join(" "); + + if (isPathNotFoundLikeError(error)) { + console.error(prefix || "(error)", "\n", error); + return; + } + + logger.error(prefix, "\n", error); }); }); diff --git a/static/.gitignore b/static/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/static/upload/a.txt b/static/upload/a.txt new file mode 100644 index 0000000..43e4382 --- /dev/null +++ b/static/upload/a.txt @@ -0,0 +1 @@ +sadsadsad \ No newline at end of file