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 = "";
+ test("converts /static/media image links to absolute URLs", () => {
+ const markdown = "";
const result = normalizeMarkdownImageUrls(markdown, "https://example.com");
- expect(result).toBe("");
+ expect(result).toBe("");
});
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 ";
+ const input = "cover ";
expect(normalizeMarkdownImageUrls(input, "https://example.com")).toBe(
- "cover ",
+ "cover ",
);
});
@@ -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 = "";
+ const body = "";
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
-
+



-[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」** 与 **「复制 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 示例
-- 输入:``
+- 输入:``
- 当前访问域名:`https://example.com`
-- 输出:``
+- 输出:``
## 6. 交互与错误处理
@@ -92,7 +92,7 @@
### 8.2 图片链接验收
-- `` 转换为当前域名下绝对 URL。
+- `` 转换为当前域名下绝对 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("")).toEqual(["/public/upload/a.webp"]);
+ test("accepts site-relative asset URL without allowed origins", () => {
+ expect(extractMediaUrlsFromMarkdown(``)).toEqual([
+ `${POST_MEDIA_PUBLIC_PREFIX}a.webp`,
+ ]);
});
test("rejects absolute URL when no allowed origins", () => {
- expect(extractMediaUrlsFromMarkdown("")).toEqual([]);
+ expect(extractMediaUrlsFromMarkdown(``)).toEqual([]);
});
test("accepts absolute URL when origin matches allowed list", () => {
expect(
- extractMediaUrlsFromMarkdown("", {
+ extractMediaUrlsFromMarkdown(``, {
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("", {
+ extractMediaUrlsFromMarkdown(``, {
allowedAssetOrigins: ["https://blog.example.com"],
}),
).toEqual([]);
});
test("strips query on relative asset URL", () => {
- expect(extractMediaUrlsFromMarkdown("")).toEqual(["/public/upload/c.webp"]);
+ expect(extractMediaUrlsFromMarkdown(``)).toEqual([
+ `${POST_MEDIA_PUBLIC_PREFIX}c.webp`,
+ ]);
});
test("strips query on absolute asset URL via pathname", () => {
expect(
- extractMediaUrlsFromMarkdown("", {
+ extractMediaUrlsFromMarkdown(``, {
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)) {