diff --git a/.env.example b/.env.example index 8d5cd60..78de313 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ # DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres DATABASE_URL=file:./db.sqlite NITRO_PORT=3399 -# 站点对外根 URL(含协议与域名,可带端口)。用于:① 媒体库复制绝对链接 ② 文章/资料里绝对地址图片是否计为本站 /public/upload/ 引用。生产环境务必设置,与浏览器访问地址一致。 +STATIC_DIR=static +MEDIA_UPLOAD_SUBDIR=media +TMP_DIR=.tmp +# 站点对外根 URL(含协议与域名,可带端口)。用于:① 媒体库复制绝对链接 ② 文章/资料里绝对地址图片是否计为本站 /static/media/ 引用。生产环境务必设置,与浏览器访问地址一致。 NUXT_PUBLIC_SITE_URL=https://example.com # Optional: first admin for an empty instance. Creates an admin only when no user has role=admin yet (same username/password rules as registration). BOOTSTRAP_ADMIN_USERNAME= diff --git a/.gitignore b/.gitignore index a58678c..27572c7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ logs # Local git worktrees .worktrees + +static/* \ No newline at end of file diff --git a/app/components/post-body-markdown-editor-vditor-config.test.ts b/app/components/post-body-markdown-editor-vditor-config.test.ts index 87020e7..6959266 100644 --- a/app/components/post-body-markdown-editor-vditor-config.test.ts +++ b/app/components/post-body-markdown-editor-vditor-config.test.ts @@ -63,7 +63,7 @@ describe('PostBodyMarkdownEditor Vditor config', () => { const result = options.upload?.format?.(files, JSON.stringify({ code: 0, data: { - files: [{ url: '/public/upload/abc.webp' }], + files: [{ url: '/static/media/abc.webp' }], }, })) expect(result).toBe(JSON.stringify({ @@ -72,7 +72,7 @@ describe('PostBodyMarkdownEditor Vditor config', () => { data: { errFiles: [], succMap: { - 'image.webp': '/public/upload/abc.webp', + 'image.webp': '/static/media/abc.webp', }, }, })) diff --git a/app/pages/me/admin/media-storage.vue b/app/pages/me/admin/media-storage.vue index 80f26e3..92dfb65 100644 --- a/app/pages/me/admin/media-storage.vue +++ b/app/pages/me/admin/media-storage.vue @@ -195,7 +195,7 @@ onMounted(async () => { 媒体存储校验

- 比对 public/upload 与表 media_assets: + 比对 static/media 与表 media_assets: 库中有记录但文件缺失、非法 storageKey、以及磁盘上未登记的文件。 「一键清理」仅删除无 media_refs 引用磁盘上确实没有文件的库记录,不会删磁盘文件。 对有引用但缺文件的记录可使用重新上传按原 storage_key 写回 WebP,不破坏文章/资料中的链接。 diff --git a/app/utils/markdown-export.test.ts b/app/utils/markdown-export.test.ts index 2106d6f..49d8949 100644 --- a/app/utils/markdown-export.test.ts +++ b/app/utils/markdown-export.test.ts @@ -6,11 +6,11 @@ import { } from "./markdown-export"; describe("normalizeMarkdownImageUrls", () => { - test("converts /public/upload image links to absolute URLs", () => { - const markdown = "![cover](/public/upload/posts/cover.png)"; + test("converts /static/media image links to absolute URLs", () => { + const markdown = "![cover](/static/media/posts/cover.png)"; const result = normalizeMarkdownImageUrls(markdown, "https://example.com"); - expect(result).toBe("![cover](https://example.com/public/upload/posts/cover.png)"); + expect(result).toBe("![cover](https://example.com/static/media/posts/cover.png)"); }); test("converts other site-relative image links to absolute URLs", () => { @@ -43,7 +43,7 @@ describe("normalizeMarkdownImageUrls", () => { }); test("does not change normal markdown links", () => { - const markdown = "[read more](/public/upload/posts/cover.png)"; + const markdown = "[read more](/static/media/posts/cover.png)"; const result = normalizeMarkdownImageUrls(markdown, "https://example.com"); expect(result).toBe(markdown); diff --git a/docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md b/docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md index c655c68..de36081 100644 --- a/docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md +++ b/docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md @@ -4,7 +4,7 @@ **Goal:** 按 `docs/superpowers/specs/2026-04-18-post-media-assets-design.md` 实现文章图片的 `media_assets` / `post_media_refs`、保存文章时同步引用、上传后 Sharp 转 WebP 与限宽、`/me` 孤儿审查与手动删除、管理员全站自动清扫开关 + 定时任务;默认不自动删文件。 -**Architecture:** 引用真相在 `post_media_refs`;`media_assets` 存 `storage_key`(与 `/public/assets/` 一一对应)、`first_referenced_at` / `dereferenced_at` 用于宽限期判定(从未被引用用 `created_at`,曾被引用用 `dereferenced_at`)。`createPost` / `updatePost` / `deletePost` 后调用同一套 `syncPostMediaRefs` + `reconcileAssetReferenceTimestamps`。上传走 multer 落盘后经 Sharp 写出 `.webp` 主文件并插入 DB。定时任务通过 `getGlobalConfigValue("mediaOrphanAutoSweepEnabled")` 判断是否执行删除,与手动删除共用 `purgeDeletableOrphans` 核心逻辑。 +**Architecture:** 引用真相在 `post_media_refs`;`media_assets` 存 `storage_key`(与 `/static/media/` 一一对应)、`first_referenced_at` / `dereferenced_at` 用于宽限期判定(从未被引用用 `created_at`,曾被引用用 `dereferenced_at`)。`createPost` / `updatePost` / `deletePost` 后调用同一套 `syncPostMediaRefs` + `reconcileAssetReferenceTimestamps`。上传走 multer 落盘后经 Sharp 写出 `.webp` 主文件并插入 DB。定时任务通过 `getGlobalConfigValue("mediaOrphanAutoSweepEnabled")` 判断是否执行删除,与手动删除共用 `purgeDeletableOrphans` 核心逻辑。 **Tech Stack:** Nuxt 4.4 / Nitro、Drizzle + SQLite(`drizzle-pkg`)、Bun、`sharp`(图片处理)、现有 `multer` 上传、`#server/service/config` 全局配置、`requireAdmin` / `event.context.auth.requireUser()`。 @@ -139,7 +139,7 @@ cd /home/dash/projects/person-panel && git add packages/drizzle-pkg && git commi ```typescript /** 与 `upload` 返回及静态路径一致,无前导 host */ -export const POST_MEDIA_PUBLIC_PREFIX = "/public/assets/"; +export const POST_MEDIA_PUBLIC_PREFIX = "/static/media/"; /** 从未引用:created_at 起算;曾引用:dereferenced_at 起算 */ export const MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF = 24; @@ -148,7 +148,7 @@ export const MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF = 24; export const MEDIA_IMAGE_MAX_WIDTH_PX = 1920; export const MEDIA_WEBP_QUALITY = 82; -export const RELATIVE_ASSETS_DIR = "public/assets"; +export const RELATIVE_ASSETS_DIR = "static/media"; ``` - [ ] **Step 2: 新增 `server/utils/post-media-urls.ts`** @@ -203,7 +203,7 @@ export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null return a; } -/** `/public/assets/foo.webp` → `foo.webp` */ +/** `/static/media/foo.webp` → `foo.webp` */ export function publicAssetUrlToStorageKey(url: string): string | null { const t = url.trim(); if (!t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) { @@ -339,7 +339,7 @@ cd /home/dash/projects/person-panel && bun add sharp - [ ] **Step 2: 改写上传 handler(逻辑要点)** 1. `const user = await event.context.auth.requireUser();`(与 `server/api/me/posts.post.ts` 一致)。 -2. multer **仍用 `diskStorage`** 到 `public/assets`,先得到原始扩展名文件。 +2. multer **仍用 `diskStorage`** 到 `static/media`,先得到原始扩展名文件。 3. 对每个已上传文件: - `sharp(path).rotate()`(尊重 EXIF) - `.resize({ width: MEDIA_IMAGE_MAX_WIDTH_PX, height: MEDIA_IMAGE_MAX_WIDTH_PX, fit: "inside", withoutEnlargement: true })` @@ -348,7 +348,7 @@ cd /home/dash/projects/person-panel && bun add sharp 4. 写成功后 **删除** 原始 multer 文件(若与目标路径不同)。 5. `insertMediaAssetRow({ userId: user.id, storageKey: filename, mime: "image/webp", sizeBytes: stat.size, ... })`。 6. 若 Sharp 抛错:**删除** 已生成的部分文件,不插入 DB,整请求失败。 -7. 响应 `url` 为 `/public/assets/.webp`,`mimeType` / `size` 为 WebP。 +7. 响应 `url` 为 `/static/media/.webp`,`mimeType` / `size` 为 WebP。 - [ ] **Step 3: `bun run build`** diff --git a/docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md b/docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md index 666577b..474a71a 100644 --- a/docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md +++ b/docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md @@ -4,7 +4,7 @@ **Goal:** 按 `docs/superpowers/specs/2026-04-19-media-library-design.md` 实现 **`GET /api/me/media/assets`**、**`/me/media` 父壳 + 资源库子页**、将 **孤儿页** 纳入同一壳;更新 **`AppShell`** 与 **`/me` 控制台首页** 导航。 -**Architecture:** 列表数据在 `server/service/media` 中新增「按用户分页列出 `media_assets` + 批量聚合 `media_refs` 计数」函数(两阶段查询,避免复杂 join)。Nuxt 使用 **`app/pages/me/media.vue`** 包裹 **``**,子路由 **`media/index.vue`**(资源库)、**`media/orphans.vue`**(现有页面逻辑迁移路径不变)。前端复制使用 **`window.location.origin + '/public/assets/' + storageKey`**。上传复用 **`POST /api/file/upload`** 与资料页相同的 `fetchData` + `FormData` 模式。 +**Architecture:** 列表数据在 `server/service/media` 中新增「按用户分页列出 `media_assets` + 批量聚合 `media_refs` 计数」函数(两阶段查询,避免复杂 join)。Nuxt 使用 **`app/pages/me/media.vue`** 包裹 **``**,子路由 **`media/index.vue`**(资源库)、**`media/orphans.vue`**(现有页面逻辑迁移路径不变)。前端复制使用 **`window.location.origin + '/static/media/' + storageKey`**。上传复用 **`POST /api/file/upload`** 与资料页相同的 `fetchData` + `FormData` 模式。 **Tech Stack:** Nuxt 4.4、Nitro、`#server/service/media`(Drizzle + `dbGlobal`)、`mediaAssets` / `mediaRefs`、`defineWrappedResponseHandler`、`R.success`、`event.context.auth.requireUser()`、Bun test(`bun:test`)、Nuxt UI(`UContainer`、`UCard`、`UPagination`、`UButton`、`UFileUpload` 或隐藏 file input,与现有页面风格一致)。 @@ -245,7 +245,7 @@ export default defineWrappedResponseHandler(async (event) => { items: items.map((r) => ({ id: r.id, storageKey: r.storageKey, - publicPath: `/public/assets/${r.storageKey}`, + publicPath: `/static/media/${r.storageKey}`, mime: r.mime, sizeBytes: r.sizeBytes, createdAt: r.createdAt.toISOString(), diff --git a/docs/superpowers/plans/2026-04-23-article-markdown-export-implementation-plan.md b/docs/superpowers/plans/2026-04-23-article-markdown-export-implementation-plan.md index 1b72221..6c20bf8 100644 --- a/docs/superpowers/plans/2026-04-23-article-markdown-export-implementation-plan.md +++ b/docs/superpowers/plans/2026-04-23-article-markdown-export-implementation-plan.md @@ -37,9 +37,9 @@ import { buildMarkdownExportFileName, normalizeMarkdownImageUrls } from "./markd describe("normalizeMarkdownImageUrls", () => { test("converts site-relative image links to absolute", () => { - const input = "cover ![](/public/assets/u1/a.webp)"; + const input = "cover ![](/static/media/u1/a.webp)"; expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe( - "cover ![](https://example.com/public/assets/u1/a.webp)", + "cover ![](https://example.com/static/media/u1/a.webp)", ); }); @@ -54,7 +54,7 @@ describe("normalizeMarkdownImageUrls", () => { }); test("does not rewrite normal markdown links", () => { - const input = "[asset](/public/assets/u1/a.webp)"; + const input = "[asset](/static/media/u1/a.webp)"; expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(input); }); }); @@ -143,9 +143,9 @@ import { normalizeMarkdownImageUrls } from "../../../utils/markdown-export"; describe("public post export contract", () => { test("normalizes site image link for page-export flow", () => { - const body = "![img](/public/assets/x.png)"; + const body = "![img](/static/media/x.png)"; expect(normalizeMarkdownImageUrls(body, "https://site.local")).toContain( - "https://site.local/public/assets/x.png", + "https://site.local/static/media/x.png", ); }); }); @@ -235,11 +235,11 @@ Manual checklist: Use a post containing all sample links and validate output: ```md -![local](/public/assets/u1/a.webp) +![local](/static/media/u1/a.webp) ![abs](https://cdn.example.com/a.png) ![proto](//cdn.example.com/a.png) ![data](data:image/png;base64,AAAA) -[plain](/public/assets/u1/a.webp) +[plain](/static/media/u1/a.webp) ``` Expected in exported file: diff --git a/docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md b/docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md index 4708b0f..49e0e6b 100644 --- a/docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md +++ b/docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md @@ -53,7 +53,7 @@ - **`limits.fileSize`**:改为 **10 * 1024 * 1024**(10MB)。 - **`fileFilter`**:**不变**(仍为 PNG / JPG / WebP)。 -- 响应格式 **不变**:`R.success({ files: [{ name, url, path, mimeType, size }, ...] })`,`url` 仍为 **`/public/assets/...`** 形式,与静态资源一致。 +- 响应格式 **不变**:`R.success({ files: [{ name, url, path, mimeType, size }, ...] })`,`url` 仍为 **`/static/media/...`** 形式,与静态资源一致。 **文章 CRUD**:`bodyMarkdown` 仍为纯文本存储;**无需**迁移或新表。 diff --git a/docs/superpowers/specs/2026-04-18-post-media-assets-design.md b/docs/superpowers/specs/2026-04-18-post-media-assets-design.md index 677deef..eae8708 100644 --- a/docs/superpowers/specs/2026-04-18-post-media-assets-design.md +++ b/docs/superpowers/specs/2026-04-18-post-media-assets-design.md @@ -5,7 +5,7 @@ ## 1. 背景与目标 -当前 `POST /api/file/upload` 将图片写入 `public/assets/`,返回 `/public/assets/...` URL;`posts.bodyMarkdown` 与 `coverUrl` 仅保存字符串,**磁盘文件与数据库无引用关系**。导致: +当前 `POST /api/file/upload` 将图片写入 `static/media/`,返回 `/static/media/...` URL;`posts.bodyMarkdown` 与 `coverUrl` 仅保存字符串,**磁盘文件与数据库无引用关系**。导致: - 删改文章后易产生 **无人引用的孤儿文件**,磁盘只增不减。 - 缺少结构化元数据,难以做 **压缩、限宽、多格式** 等优化与后续 CDN 迁移。 @@ -29,7 +29,7 @@ |------|------| | `id` | 主键 | | `userId` | 所有者;所有查询与删除必须 **按用户隔离** | -| `storageKey` | 相对存储根的路径或稳定键,与对外 URL 可逆映射(与现 `public/assets` 约定对齐) | +| `storageKey` | 相对存储根的路径或稳定键,与对外 URL 可逆映射(与现 `static/media` 约定对齐) | | `mime` | 存储的主资源 MIME(优化后多为 `image/webp`) | | `sizeBytes` | 主资源字节数 | | `sha256` | 可选;用于去重或完整性 | @@ -49,7 +49,7 @@ ## 5. 引用解析规则 - **范围**:`coverUrl` + `bodyMarkdown` 中出现的图片 URL。 -- **识别本站资源**:与当前静态资源约定一致的前缀(例如 `/public/assets/`);实现计划锁定正则或解析器,避免误把外链当站内。 +- **识别本站资源**:与当前静态资源约定一致的前缀(例如 `/static/media/`);实现计划锁定正则或解析器,避免误把外链当站内。 - **外链**:忽略,不创建、不删除 `media_assets`。 ## 6. 孤儿回收(A) @@ -117,7 +117,7 @@ ### 7.1 首版能力 - 上传后:**解码** → **限最大宽度**(默认 **1920px**,实现计划可配置)→ 输出 **WebP**(质量默认 **82**,可配置)。 -- Markdown 与上传 API 返回的 `url` **指向优化后的主资源**(路径规则与现 `public/assets` 命名兼容或可迁移)。 +- Markdown 与上传 API 返回的 `url` **指向优化后的主资源**(路径规则与现 `static/media` 命名兼容或可迁移)。 - **是否保留原图**:首版 **可不保留**(仅 WebP),以减少复杂度与存储;若产品要求可回滚原图,则在 `variantsJson` 中保留 `original` 键并在实现计划中写清磁盘布局。 ### 7.2 二期(非首版验收) diff --git a/docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md b/docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md index 1d0261c..a1e0e7b 100644 --- a/docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md +++ b/docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md @@ -6,7 +6,7 @@ ## 1. 背景与目标 - 公开主页 `/@:publicSlug` 已展示 `users.bio_markdown`(在 `bioVisibility === public` 时经聚合接口下发),但**没有独立路由**承载「生平」长文的阅读体验。 -- 文章图片的引用真相在 `post_media_refs`;**个人简介 Markdown 与头像 URL** 若包含本站 `/public/assets/...` 资源,当前**不会**同步到引用表,孤儿审查可能误判或漏判。 +- 文章图片的引用真相在 `post_media_refs`;**个人简介 Markdown 与头像 URL** 若包含本站 `/static/media/...` 资源,当前**不会**同步到引用表,孤儿审查可能误判或漏判。 **目标**: diff --git a/docs/superpowers/specs/2026-04-19-media-library-design.md b/docs/superpowers/specs/2026-04-19-media-library-design.md index b363956..bfeb70f 100644 --- a/docs/superpowers/specs/2026-04-19-media-library-design.md +++ b/docs/superpowers/specs/2026-04-19-media-library-design.md @@ -11,7 +11,7 @@ - **核心价值**:以 **上传 + 选用(复制)** 为主,列表浏览为辅。 - **首版范围**:**媒体库页面 + 复制公开 URL + 复制 `![](url)`**,不在文章/资料编辑器内做集成。 - **复制形态**:同时提供 **「复制图片 URL」** 与 **「复制 Markdown 图片」** 两种操作(两个按钮或等价 UI)。 -- **URL 策略**:在浏览器端使用 **`window.location.origin + '/public/assets/'`** 生成 **绝对 URL**,两种复制均使用该地址,避免相对路径在外部编辑器中失效。 +- **URL 策略**:在浏览器端使用 **`window.location.origin + '/static/media/'`** 生成 **绝对 URL**,两种复制均使用该地址,避免相对路径在外部编辑器中失效。 - **导航**:**单一「媒体」入口**,下挂 **资源库** 与 **孤儿清理** 两个子能力。 - **首版不做**:媒体库内对仍被引用的资源的一键删除(避免与 `media_refs` / 正文同步规则冲突);孤儿删除与宽限逻辑 **继续仅在「孤儿清理」子页** 完成。 @@ -69,7 +69,7 @@ - **鉴权**:必须登录;仅返回 **当前用户** 的 `media_assets`。 - **Query**:`page`(从 1 起)、`pageSize`(允许值与前端分页选项对齐,服务端需校验上限)。 - **响应**(示意):`{ items: [...], total: number }` - 每项至少包含:`id`、`storageKey`(或等价字段)、`sizeBytes`、`createdAt`(ISO 或统一项目惯例)、`mime`、`refCount`;**公开路径**可与前端约定为固定前缀 `/public/assets/` + `storageKey`,或由服务端返回 `publicPath` 以避免重复逻辑。 + 每项至少包含:`id`、`storageKey`(或等价字段)、`sizeBytes`、`createdAt`(ISO 或统一项目惯例)、`mime`、`refCount`;**公开路径**可与前端约定为固定前缀 `/static/media/` + `storageKey`,或由服务端返回 `publicPath` 以避免重复逻辑。 - **安全**:禁止按参数指定其他 `userId`;不得泄露其他用户资源。 列表查询实现可放在 `server/service/media` 或并列模块,保持 handler 轻薄。 diff --git a/docs/superpowers/specs/2026-04-23-article-markdown-export-design.md b/docs/superpowers/specs/2026-04-23-article-markdown-export-design.md index d3246cf..0bd9d5d 100644 --- a/docs/superpowers/specs/2026-04-23-article-markdown-export-design.md +++ b/docs/superpowers/specs/2026-04-23-article-markdown-export-design.md @@ -30,7 +30,7 @@ 1. 在详情页数据加载完成后显示 `导出 .md` 按钮。 2. 点击按钮时读取当前已加载的 `data.bodyMarkdown`。 3. 仅对 Markdown 图片语法 `![](...)` 中的链接做归一化: - - 若为站内相对路径(如 `/public/assets/...`),替换为 `window.location.origin + path`。 + - 若为站内相对路径(如 `/static/media/...`),替换为 `window.location.origin + path`。 - 其他链接类型保持不变。 4. 生成 `Blob(['...'], { type: 'text/markdown;charset=utf-8' })`。 5. 通过临时 `` 触发下载,文件名按规则生成。 @@ -52,7 +52,7 @@ ### 5.3 转换条件 -- 以 `/` 开头的站内相对路径(重点覆盖 `/public/assets/...`)。 +- 以 `/` 开头的站内相对路径(重点覆盖 `/static/media/...`)。 ### 5.4 基地址来源 @@ -60,9 +60,9 @@ ### 5.5 示例 -- 输入:`![](/public/assets/u1/a.webp)` +- 输入:`![](/static/media/u1/a.webp)` - 当前访问域名:`https://example.com` -- 输出:`![](https://example.com/public/assets/u1/a.webp)` +- 输出:`![](https://example.com/static/media/u1/a.webp)` ## 6. 交互与错误处理 @@ -92,7 +92,7 @@ ### 8.2 图片链接验收 -- `![](/public/assets/xxx.png)` 转换为当前域名下绝对 URL。 +- `![](/static/media/xxx.png)` 转换为当前域名下绝对 URL。 - 已是绝对地址、协议相对地址、data URI 的图片链接保持不变。 - 普通链接 `[](...)` 不被误改。 diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 1430fcb..3d57799 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/public/upload/1777047996547-912573909-image.webp b/public/upload/1777047996547-912573909-image.webp deleted file mode 100644 index 25b6d7d..0000000 Binary files a/public/upload/1777047996547-912573909-image.webp and /dev/null differ diff --git a/server/api/file/upload.post.ts b/server/api/file/upload.post.ts index c640a68..b3b1bca 100644 --- a/server/api/file/upload.post.ts +++ b/server/api/file/upload.post.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import sharp from 'sharp'; import { callNodeListener } from 'h3'; import { R } from '#server/utils/response'; -import { MEDIA_IMAGE_MAX_WIDTH_PX, MEDIA_WEBP_QUALITY } from '#server/constants/media'; +import { MEDIA_IMAGE_MAX_WIDTH_PX, MEDIA_WEBP_QUALITY, POST_MEDIA_PUBLIC_PREFIX, RELATIVE_ASSETS_DIR } from '#server/constants/media'; import { insertMediaAssetRow } from '#server/service/media'; import { assertDiskFileIsAllowedRasterImage, IMAGE_MAGIC_MISMATCH_MESSAGE } from '#server/utils/image-magic-bytes'; @@ -30,7 +30,7 @@ export default defineWrappedResponseHandler(async (event) => { const user = await event.context.auth.requireUser(); // 存储目录 - const uploadDir = path.join(process.cwd(), 'public/upload'); + const uploadDir = path.join(process.cwd(), RELATIVE_ASSETS_DIR); // 自动创建目录 if (!fs.existsSync(uploadDir)) { @@ -144,7 +144,7 @@ export default defineWrappedResponseHandler(async (event) => { result.push({ name: file.originalname, - url: `/public/upload/${finalName}`, + url: `${POST_MEDIA_PUBLIC_PREFIX}${finalName}`, mimeType: 'image/webp', size: stat.size, path: finalPath, diff --git a/server/constants/media.ts b/server/constants/media.ts index d5d7f06..1b03df6 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -1,5 +1,24 @@ -/** 与 `upload` 返回及静态路径一致,无前导 host */ -export const POST_MEDIA_PUBLIC_PREFIX = "/public/upload/"; +function trimSlashes(input: string): string { + return input.trim().replace(/^\/+|\/+$/g, ""); +} + +/** 静态资源 URL 前缀固定为 `/static`,不允许通过环境变量覆写。 */ +export const STATIC_PUBLIC_PREFIX = "/static"; + +/** 静态资源根目录,默认 `static` */ +export const STATIC_DIR = trimSlashes(process.env.STATIC_DIR ?? "static"); + +/** 媒体上传子目录(相对 STATIC_DIR),默认 `media` */ +export const MEDIA_UPLOAD_SUBDIR = trimSlashes(process.env.MEDIA_UPLOAD_SUBDIR ?? "media"); + +/** 媒体上传目录(相对项目根),默认 `static/media` */ +export const RELATIVE_ASSETS_DIR = `${STATIC_DIR}/${MEDIA_UPLOAD_SUBDIR}`; + +/** 临时目录(相对项目根),默认 `.tmp` */ +export const RELATIVE_TMP_DIR = trimSlashes(process.env.TMP_DIR ?? ".tmp"); + +/** 与 `media` 返回及静态路径一致,无前导 host */ +export const POST_MEDIA_PUBLIC_PREFIX = `/${RELATIVE_ASSETS_DIR}/`; /** 从未引用:created_at 起算;曾引用:dereferenced_at 起算 */ export const MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF = 24; @@ -7,5 +26,3 @@ export const MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF = 24; export const MEDIA_IMAGE_MAX_WIDTH_PX = 1920; export const MEDIA_WEBP_QUALITY = 82; - -export const RELATIVE_ASSETS_DIR = "public/upload"; diff --git a/server/middleware/00.public.ts b/server/middleware/00.public.ts index 88a6eb4..c2003e2 100644 --- a/server/middleware/00.public.ts +++ b/server/middleware/00.public.ts @@ -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/media"; 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,7 +30,7 @@ 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)) ); diff --git a/server/service/export/jobs.ts b/server/service/export/jobs.ts index 9f75254..366c9b2 100644 --- a/server/service/export/jobs.ts +++ b/server/service/export/jobs.ts @@ -4,11 +4,12 @@ import { dbGlobal } from "drizzle-pkg/lib/db"; import { userExportTasks } from "drizzle-pkg/lib/schema/export"; import { and, desc, eq, or } from "drizzle-orm"; import { nextIntegerId } from "../../utils/sqlite-id"; +import { RELATIVE_TMP_DIR } from "#server/constants/media"; type ExportMaskPolicy = "masked" | "raw"; function exportRootDir(): string { - return path.resolve(process.cwd(), ".tmp", "exports"); + return path.resolve(process.cwd(), RELATIVE_TMP_DIR, "exports"); } function isPathUnderExportRoot(dir: string): boolean { @@ -17,6 +18,18 @@ function isPathUnderExportRoot(dir: string): boolean { return resolved === root || resolved.startsWith(`${root}${path.sep}`); } +/** + * 允许清理历史 TMP_DIR 下的导出目录: + * - 父目录必须是 exports + * - 目录名必须形如 export-task- + */ +function isSafeLegacyTaskOutputDir(dir: string): boolean { + const resolved = path.resolve(dir); + const parent = path.dirname(resolved); + const name = path.basename(resolved); + return path.basename(parent) === "exports" && /^export-task-\d+$/.test(name); +} + async function getExportTaskById(taskId: number) { const [row] = await dbGlobal .select() @@ -186,7 +199,7 @@ export async function deleteExportTaskForUser(taskId: number, userId: number) { throw { statusCode: 409, statusMessage: "任务处理中,暂不可删除" }; } - if (task.outputDir && isPathUnderExportRoot(task.outputDir)) { + if (task.outputDir && (isPathUnderExportRoot(task.outputDir) || isSafeLegacyTaskOutputDir(task.outputDir))) { try { await fs.rm(task.outputDir, { recursive: true, force: true }); } catch { diff --git a/server/service/export/run.ts b/server/service/export/run.ts index fba9fa7..0a099dd 100644 --- a/server/service/export/run.ts +++ b/server/service/export/run.ts @@ -9,11 +9,12 @@ import { markExportTaskSucceeded, } from "#server/service/export/jobs"; import { sha256File } from "#server/utils/export-hash"; +import { RELATIVE_TMP_DIR } from "#server/constants/media"; const EXPORT_RESULT_TTL_MS = 24 * 60 * 60 * 1000; function resolveExportRootDir() { - return path.resolve(process.cwd(), ".tmp", "exports"); + return path.resolve(process.cwd(), RELATIVE_TMP_DIR, "exports"); } async function calcChecksums(baseDir: string, files: Array<{ file: string }>) { diff --git a/server/utils/post-media-urls.test.ts b/server/utils/post-media-urls.test.ts index 41f5ca6..44f39bc 100644 --- a/server/utils/post-media-urls.test.ts +++ b/server/utils/post-media-urls.test.ts @@ -1,41 +1,46 @@ import { describe, expect, test } from "bun:test"; +import { POST_MEDIA_PUBLIC_PREFIX } from "#server/constants/media"; import { extractMediaUrlsFromMarkdown, publicAssetUrlToStorageKey } from "./post-media-urls"; describe("extractMediaUrlsFromMarkdown", () => { - test("accepts site-relative /public/upload/ URL without allowed origins", () => { - expect(extractMediaUrlsFromMarkdown("![](/public/upload/a.webp)")).toEqual(["/public/upload/a.webp"]); + test("accepts site-relative asset URL without allowed origins", () => { + expect(extractMediaUrlsFromMarkdown(`![](${POST_MEDIA_PUBLIC_PREFIX}a.webp)`)).toEqual([ + `${POST_MEDIA_PUBLIC_PREFIX}a.webp`, + ]); }); test("rejects absolute URL when no allowed origins", () => { - expect(extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/upload/b.webp)")).toEqual([]); + expect(extractMediaUrlsFromMarkdown(`![](https://blog.example.com${POST_MEDIA_PUBLIC_PREFIX}b.webp)`)).toEqual([]); }); test("accepts absolute URL when origin matches allowed list", () => { expect( - extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/upload/b.webp)", { + extractMediaUrlsFromMarkdown(`![](https://blog.example.com${POST_MEDIA_PUBLIC_PREFIX}b.webp)`, { allowedAssetOrigins: ["https://blog.example.com"], }), - ).toEqual(["/public/upload/b.webp"]); + ).toEqual([`${POST_MEDIA_PUBLIC_PREFIX}b.webp`]); }); test("rejects absolute URL when origin does not match", () => { expect( - extractMediaUrlsFromMarkdown("![](https://evil.example/public/upload/b.webp)", { + extractMediaUrlsFromMarkdown(`![](https://evil.example${POST_MEDIA_PUBLIC_PREFIX}b.webp)`, { allowedAssetOrigins: ["https://blog.example.com"], }), ).toEqual([]); }); test("strips query on relative asset URL", () => { - expect(extractMediaUrlsFromMarkdown("![](/public/upload/c.webp?v=1)")).toEqual(["/public/upload/c.webp"]); + expect(extractMediaUrlsFromMarkdown(`![](${POST_MEDIA_PUBLIC_PREFIX}c.webp?v=1)`)).toEqual([ + `${POST_MEDIA_PUBLIC_PREFIX}c.webp`, + ]); }); test("strips query on absolute asset URL via pathname", () => { expect( - extractMediaUrlsFromMarkdown("![](https://x.example/public/upload/d.webp?cache=1)", { + extractMediaUrlsFromMarkdown(`![](https://x.example${POST_MEDIA_PUBLIC_PREFIX}d.webp?cache=1)`, { allowedAssetOrigins: ["https://x.example"], }), - ).toEqual(["/public/upload/d.webp"]); + ).toEqual([`${POST_MEDIA_PUBLIC_PREFIX}d.webp`]); }); test("ignores non-asset absolute URLs", () => { @@ -49,6 +54,6 @@ describe("extractMediaUrlsFromMarkdown", () => { describe("publicAssetUrlToStorageKey", () => { test("maps normalized path to storage key", () => { - expect(publicAssetUrlToStorageKey("/public/upload/z.webp")).toBe("z.webp"); + expect(publicAssetUrlToStorageKey(`${POST_MEDIA_PUBLIC_PREFIX}z.webp`)).toBe("z.webp"); }); }); diff --git a/server/utils/post-media-urls.ts b/server/utils/post-media-urls.ts index 95b1ed1..e09f22e 100644 --- a/server/utils/post-media-urls.ts +++ b/server/utils/post-media-urls.ts @@ -5,7 +5,7 @@ const MD_IMG_RE = /!\[[^\]]*]\(([^)]+)\)/g; export type MergePostMediaUrlsOptions = { /** * 允许识别为「本站绝对地址」的 origin 列表(每项可为完整 URL,仅取其 `origin` 参与匹配)。 - * 为空时:不将含域名的绝对 URL 计为本站资源(仅 `/public/upload/...` 相对路径有效)。 + * 为空时:不将含域名的绝对 URL 计为本站资源(仅 `${POST_MEDIA_PUBLIC_PREFIX}...` 相对路径有效)。 */ allowedAssetOrigins?: readonly string[]; }; @@ -33,7 +33,7 @@ function buildAllowedOriginSet(entries: readonly string[] | undefined): Set`,供 `publicAssetUrlToStorageKey` 与引用同步使用。 */ +/** 统一为 `${POST_MEDIA_PUBLIC_PREFIX}`,供 `publicAssetUrlToStorageKey` 与引用同步使用。 */ function normalizeUrl(raw: string, allowedOrigins: Set): string { const t = raw.trim().replace(/^<|>$/g, "").split(/\s+/)[0] ?? ""; if (t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) { @@ -111,7 +111,7 @@ export function mergeProfileMediaUrls( return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null, options); } -/** `/public/upload/foo.webp` → `foo.webp` */ +/** `${POST_MEDIA_PUBLIC_PREFIX}foo.webp` → `foo.webp` */ export function publicAssetUrlToStorageKey(url: string): string | null { const t = url.trim(); if (!t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {