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 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("")).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)) {