From 64c2313a6d9d2697f865527d357f9cee8272f7cd Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sat, 25 Apr 2026 13:12:50 +0800 Subject: [PATCH] feat(media): refactor media handling to use static directory structure - Updated the media upload process to store files in the new `static/media` directory instead of `public/upload`. - Adjusted related components and tests to reflect the new media URL structure, ensuring all references to media assets are consistent with the new path. - Enhanced the environment configuration to support dynamic directory settings for media uploads and static assets. - Improved documentation to outline the changes in media asset management and URL handling. These changes streamline media management and improve the organization of static assets within the project. --- .env.example | 5 ++++- .gitignore | 2 ++ ...post-body-markdown-editor-vditor-config.test.ts | 4 ++-- app/pages/me/admin/media-storage.vue | 2 +- app/utils/markdown-export.test.ts | 8 +++---- ...-04-18-post-media-assets-implementation-plan.md | 12 +++++----- ...2026-04-19-media-library-implementation-plan.md | 4 ++-- ...-article-markdown-export-implementation-plan.md | 14 ++++++------ .../2026-04-18-article-edit-markdown-design.md | 2 +- .../specs/2026-04-18-post-media-assets-design.md | 8 +++---- ...8-public-profile-about-and-media-refs-design.md | 2 +- .../specs/2026-04-19-media-library-design.md | 4 ++-- .../2026-04-23-article-markdown-export-design.md | 10 ++++----- packages/drizzle-pkg/db.sqlite | Bin 163840 -> 163840 bytes public/upload/1777047996547-912573909-image.webp | Bin 7928 -> 0 bytes server/api/file/upload.post.ts | 6 ++--- server/constants/media.ts | 25 +++++++++++++++++---- server/middleware/00.public.ts | 7 +++--- server/service/export/jobs.ts | 17 ++++++++++++-- server/service/export/run.ts | 3 ++- server/utils/post-media-urls.test.ts | 25 ++++++++++++--------- server/utils/post-media-urls.ts | 6 ++--- 22 files changed, 104 insertions(+), 62 deletions(-) delete mode 100644 public/upload/1777047996547-912573909-image.webp 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 1430fcb5d164b90cb424162dcf9f200442b868d9..3d57799dc6e6e3eb8bfa10805a6796e50769ec1e 100644 GIT binary patch delta 1356 zcma)5VN6?96u#%ZciX-9`rawQ%2+o?HyA@xXjw~3P`2r>5ly!+`~z4DeI-PJO1l{o zmlT^gV_Y(=$=jbAr|6O?7HH%xF>woqMa;xW_JcrTqG;wH5@(o87@6Mp3e%V@^Us%a z&pqcm-#Pc(Oh(FNq_b{VerM-1usqpwzM-5DVpFFH&f&Y*iPh?~x?1#9eW>Q9TSKiu zJM9oD4fy@RVBDARCj5;}-axQ9;f)2Fd%f{+lP{47^u?QfjgY@-Uz~*e%-!QtLBUGP z#Uj$<;2T(AHSVZ6IZ{`*pYMSGQ^20iq8-}Q}j4b^3u6efJCfwPbm7^E1(^gBu{5aT5_nrdw)YJ z9ZL`NH4JL;fmp-Cd#RHQ-!E`YW;-}`ar$?FD%p~Co8WD{X*itR9ESD+B!^RJo7vQM z%XZiWw+P9R;l#j8nkd=?7dx>KxzyAe!8QEVa8z+uv9)F^+w5(JbxBFH^N?s42x-O6{YYgI1GX1m}~QqfQ|N=Zd!`RJft-@^GZ>ju|9Ud#D$&NF;K zKXQ;On*XcCxc4OIC>QXEdeu;ma+lQFQQ+ACcURI;zLtF>N@?muU!SJMwfJBx^`aJs z{MV2^toyn-uYRbLQ}nthr?$|Ja>##Ed%fSf4qG_Nahp{1fmGtsX_Kl3V*2?m&ZU3G z$Xq2dU(+n35tmL`IBU#DxqZ&<=6-NCwjj#Slci~Hy+x8+#!7|X4fh3e67LpAP7DnV H4E6sFF*9`5 delta 1320 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 i$e_@}kRcVcm`S`skAzt}QDG3SY8 { 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)) {