Browse Source

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.
main
npmrun 3 weeks ago
parent
commit
64c2313a6d
  1. 5
      .env.example
  2. 2
      .gitignore
  3. 4
      app/components/post-body-markdown-editor-vditor-config.test.ts
  4. 2
      app/pages/me/admin/media-storage.vue
  5. 8
      app/utils/markdown-export.test.ts
  6. 12
      docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md
  7. 4
      docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md
  8. 14
      docs/superpowers/plans/2026-04-23-article-markdown-export-implementation-plan.md
  9. 2
      docs/superpowers/specs/2026-04-18-article-edit-markdown-design.md
  10. 8
      docs/superpowers/specs/2026-04-18-post-media-assets-design.md
  11. 2
      docs/superpowers/specs/2026-04-18-public-profile-about-and-media-refs-design.md
  12. 4
      docs/superpowers/specs/2026-04-19-media-library-design.md
  13. 10
      docs/superpowers/specs/2026-04-23-article-markdown-export-design.md
  14. BIN
      packages/drizzle-pkg/db.sqlite
  15. BIN
      public/upload/1777047996547-912573909-image.webp
  16. 6
      server/api/file/upload.post.ts
  17. 25
      server/constants/media.ts
  18. 7
      server/middleware/00.public.ts
  19. 17
      server/service/export/jobs.ts
  20. 3
      server/service/export/run.ts
  21. 25
      server/utils/post-media-urls.test.ts
  22. 6
      server/utils/post-media-urls.ts

5
.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=

2
.gitignore

@ -28,3 +28,5 @@ logs
# Local git worktrees
.worktrees
static/*

4
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',
},
},
}))

2
app/pages/me/admin/media-storage.vue

@ -195,7 +195,7 @@ onMounted(async () => {
媒体存储校验
</h1>
<p class="text-sm text-muted mt-1 max-w-3xl">
比对 <code class="text-xs">public/upload</code> 与表 <code class="text-xs">media_assets</code>
比对 <code class="text-xs">static/media</code> 与表 <code class="text-xs">media_assets</code>
库中有记录但文件缺失非法 storageKey以及磁盘上未登记的文件
一键清理仅删除<strong> media_refs 引用</strong><strong>磁盘上确实没有文件</strong>的库记录不会删磁盘文件
对有引用但缺文件的记录可使用<strong>重新上传</strong>按原 <code class="text-xs">storage_key</code> 写回 WebP不破坏文章/资料中的链接

8
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);

12
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/<filename>` 一一对应)、`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/<filename>` 一一对应)、`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/<filename>.webp`,`mimeType` / `size` 为 WebP。
7. 响应 `url``/static/media/<filename>.webp`,`mimeType` / `size` 为 WebP。
- [ ] **Step 3: `bun run build`**

4
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`** 包裹 **`<NuxtPage />`**,子路由 **`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`** 包裹 **`<NuxtPage />`**,子路由 **`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(),

14
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:

2
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` 仍为纯文本存储;**无需**迁移或新表。

8
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 二期(非首版验收)

2
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/...` 资源,当前**不会**同步到引用表,孤儿审查可能误判或漏判。
**目标**:

4
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/<storageKey>'`** 生成 **绝对 URL**,两种复制均使用该地址,避免相对路径在外部编辑器中失效。
- **URL 策略**:在浏览器端使用 **`window.location.origin + '/static/media/<storageKey>'`** 生成 **绝对 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 轻薄。

10
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. 通过临时 `<a download>` 触发下载,文件名按规则生成。
@ -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 的图片链接保持不变。
- 普通链接 `[](...)` 不被误改。

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

BIN
public/upload/1777047996547-912573909-image.webp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

6
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,

25
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";

7
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))
);

17
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-<id>
*/
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 {

3
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 }>) {

25
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");
});
});

6
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<stri
return s;
}
/** 统一为 `/public/upload/<key>`,供 `publicAssetUrlToStorageKey` 与引用同步使用。 */
/** 统一为 `${POST_MEDIA_PUBLIC_PREFIX}<key>`,供 `publicAssetUrlToStorageKey` 与引用同步使用。 */
function normalizeUrl(raw: string, allowedOrigins: Set<string>): 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)) {

Loading…
Cancel
Save