Browse Source

feat(config): publicSiteUrl for same-origin media refs; copy uses it

Made-with: Cursor
tags/邮箱功能前置
npmrun 3 weeks ago
parent
commit
b5430ad1da
  1. 3
      app/composables/useGlobalConfig.ts
  2. 12
      app/pages/me/admin/config/index.vue
  3. 26
      app/pages/me/media/index.vue
  4. 26
      server/service/config/registry.ts
  5. 23
      server/service/media/index.ts
  6. 30
      server/utils/post-media-urls.test.ts
  7. 66
      server/utils/post-media-urls.ts

3
app/composables/useGlobalConfig.ts

@ -3,6 +3,8 @@ import { request, unwrapApiBody, type ApiResponse } from '../utils/http/factory'
export type GlobalConfig = {
siteName: string
allowRegister: boolean
/** 与后台「站点对外地址」一致;空表示未配置 */
publicSiteUrl: string
}
type GlobalConfigResult = {
@ -12,6 +14,7 @@ type GlobalConfigResult = {
const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
siteName: 'Person Panel',
allowRegister: true,
publicSiteUrl: '',
}
export function useGlobalConfig() {

12
app/pages/me/admin/config/index.vue

@ -7,6 +7,7 @@ type GlobalConfigPayload = {
config: {
siteName: string
allowRegister: boolean
publicSiteUrl: string
mediaOrphanAutoSweepEnabled: boolean
mediaOrphanAutoSweepIntervalMinutes: number
}
@ -21,6 +22,7 @@ const loading = ref(true)
const saving = ref(false)
const siteName = ref('')
const allowRegister = ref(true)
const publicSiteUrl = ref('')
const mediaOrphanAutoSweepEnabled = ref(false)
const mediaOrphanAutoSweepIntervalMinutes = ref(60)
@ -37,6 +39,7 @@ async function load() {
const { config: cfg } = await fetchData<GlobalConfigPayload>('/api/config/global')
siteName.value = cfg.siteName
allowRegister.value = cfg.allowRegister
publicSiteUrl.value = typeof cfg.publicSiteUrl === 'string' ? cfg.publicSiteUrl : ''
mediaOrphanAutoSweepEnabled.value = cfg.mediaOrphanAutoSweepEnabled
mediaOrphanAutoSweepIntervalMinutes.value = cfg.mediaOrphanAutoSweepIntervalMinutes
} finally {
@ -61,6 +64,7 @@ async function save() {
try {
await putKey('siteName', siteName.value.trim())
await putKey('allowRegister', allowRegister.value)
await putKey('publicSiteUrl', publicSiteUrl.value.trim())
await putKey('mediaOrphanAutoSweepEnabled', mediaOrphanAutoSweepEnabled.value)
await putKey('mediaOrphanAutoSweepIntervalMinutes', mediaOrphanAutoSweepIntervalMinutes.value)
await load()
@ -78,7 +82,7 @@ async function save() {
应用配置
</h1>
<p class="text-sm text-muted">
全局设置站点名称开放注册与媒体孤儿自动清扫
全局设置站点名称对外访问地址开放注册与媒体孤儿自动清扫
</p>
<UCard>
@ -99,6 +103,12 @@ async function save() {
<UCheckbox v-model="allowRegister" label="开启" />
</UFormField>
<UFormField
label="站点对外地址"
description="含协议的站点根 URL(如 https://blog.example.com)。用于识别文章/资料里以绝对地址引用的本站 /public/assets/ 图片并计入媒体引用;留空则仅识别相对路径 /public/assets/...。"
>
<UInput v-model="publicSiteUrl" maxlength="256" placeholder="https://…" />
</UFormField>
<UFormField
label="媒体孤儿自动清扫"
description="全站生效、仅删除已过宽限期且无引用的图片、建议先在「图片孤儿审查」确认。"
>

26
app/pages/me/media/index.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
import { useGlobalConfig } from '../../../composables/useGlobalConfig'
definePageMeta({ title: '媒体库' })
@ -15,8 +16,22 @@ type MediaAssetRow = {
const toast = useToast()
const { refresh: refreshAuth } = useAuthSession()
const { config, refresh: refreshGlobalConfig } = useGlobalConfig()
const { fetchData, getApiErrorMessage } = useClientApi()
/** 与全局「站点对外地址」一致时复制出的绝对链接才会被服务端计入引用;未配置则退回当前页 origin。 */
function resolveCopyOrigin(): string {
const raw = config.value.publicSiteUrl?.trim() ?? ''
if (raw) {
try {
return new URL(raw).origin
} catch {
/* 使用 window */
}
}
return typeof window !== 'undefined' ? window.location.origin : ''
}
const page = ref(1)
const pageSize = ref(20)
const items = ref<MediaAssetRow[]>([])
@ -77,9 +92,10 @@ watch([page, pageSize], () => {
void load()
})
onMounted(() => {
void refreshAuth(true)
void load()
onMounted(async () => {
await refreshAuth(true)
await refreshGlobalConfig()
await load()
})
function openFilePicker() {
@ -121,7 +137,7 @@ async function copyUrl(item: MediaAssetRow) {
if (!import.meta.client) {
return
}
const absoluteUrl = `${window.location.origin}${item.publicPath}`
const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
try {
await navigator.clipboard.writeText(absoluteUrl)
toast.add({ title: '已复制 URL', color: 'success' })
@ -134,7 +150,7 @@ async function copyMarkdown(item: MediaAssetRow) {
if (!import.meta.client) {
return
}
const absoluteUrl = `${window.location.origin}${item.publicPath}`
const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
const md = `![](${absoluteUrl})`
try {
await navigator.clipboard.writeText(md)

26
server/service/config/registry.ts

@ -31,6 +31,32 @@ const CONFIG_REGISTRY = {
defaultValue: true,
userOverridable: false,
}),
/**
* 访 Markdown/
* `/public/assets/`
*/
publicSiteUrl: defineConfig<string>({
key: "publicSiteUrl",
scope: "global",
valueType: "string",
defaultValue: "",
userOverridable: false,
validate: (value: string) => {
const t = value.trim();
if (!t) {
return true;
}
if (t.length > 256) {
return false;
}
try {
const u = new URL(t);
return (u.protocol === "http:" || u.protocol === "https:") && Boolean(u.host);
} catch {
return false;
}
},
}),
mediaOrphanAutoSweepEnabled: defineConfig<boolean>({
key: "mediaOrphanAutoSweepEnabled",
scope: "global",

23
server/service/media/index.ts

@ -10,6 +10,7 @@ import {
RELATIVE_ASSETS_DIR,
} from "#server/constants/media";
import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs";
import { getGlobalConfigValue } from "#server/service/config";
import { mergePostMediaUrls, mergeProfileMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls";
import { nextIntegerId } from "#server/utils/sqlite-id";
@ -183,6 +184,22 @@ export async function reconcileAssetTimestampsAfterRefChange(assetIds: number[])
}
}
async function allowedAssetOriginsForRefs(): Promise<string[]> {
const raw = String(await getGlobalConfigValue("publicSiteUrl")).trim();
if (!raw) {
return [];
}
try {
const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") {
return [];
}
return [u.origin];
} catch {
return [];
}
}
export async function syncPostMediaRefs(
userId: number,
postId: number,
@ -195,7 +212,8 @@ export async function syncPostMediaRefs(
await dbGlobal.delete(mediaRefs).where(postRef);
const urls = mergePostMediaUrls(bodyMarkdown, coverUrl);
const allowedOrigins = await allowedAssetOriginsForRefs();
const urls = mergePostMediaUrls(bodyMarkdown, coverUrl, { allowedAssetOrigins: allowedOrigins });
const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))];
let afterIds: number[] = [];
@ -233,7 +251,8 @@ export async function syncProfileMediaRefs(
await dbGlobal.delete(mediaRefs).where(profileRef);
const urls = mergeProfileMediaUrls(bioMarkdown, avatar);
const allowedOrigins = await allowedAssetOriginsForRefs();
const urls = mergeProfileMediaUrls(bioMarkdown, avatar, { allowedAssetOrigins: allowedOrigins });
const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))];
let afterIds: number[] = [];

30
server/utils/post-media-urls.test.ts

@ -2,28 +2,48 @@ import { describe, expect, test } from "bun:test";
import { extractMediaUrlsFromMarkdown, publicAssetUrlToStorageKey } from "./post-media-urls";
describe("extractMediaUrlsFromMarkdown", () => {
test("accepts site-relative /public/assets/ URL", () => {
test("accepts site-relative /public/assets/ URL without allowed origins", () => {
expect(extractMediaUrlsFromMarkdown("![](/public/assets/a.webp)")).toEqual(["/public/assets/a.webp"]);
});
test("accepts absolute URL with same path and normalizes to relative", () => {
test("rejects absolute URL when no allowed origins", () => {
expect(extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/assets/b.webp)")).toEqual([]);
});
test("accepts absolute URL when origin matches allowed list", () => {
expect(
extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/assets/b.webp)"),
extractMediaUrlsFromMarkdown("![](https://blog.example.com/public/assets/b.webp)", {
allowedAssetOrigins: ["https://blog.example.com"],
}),
).toEqual(["/public/assets/b.webp"]);
});
test("rejects absolute URL when origin does not match", () => {
expect(
extractMediaUrlsFromMarkdown("![](https://evil.example/public/assets/b.webp)", {
allowedAssetOrigins: ["https://blog.example.com"],
}),
).toEqual([]);
});
test("strips query on relative asset URL", () => {
expect(extractMediaUrlsFromMarkdown("![](/public/assets/c.webp?v=1)")).toEqual(["/public/assets/c.webp"]);
});
test("strips query on absolute asset URL via pathname", () => {
expect(
extractMediaUrlsFromMarkdown("![](https://x.example/public/assets/d.webp?cache=1)"),
extractMediaUrlsFromMarkdown("![](https://x.example/public/assets/d.webp?cache=1)", {
allowedAssetOrigins: ["https://x.example"],
}),
).toEqual(["/public/assets/d.webp"]);
});
test("ignores non-asset absolute URLs", () => {
expect(extractMediaUrlsFromMarkdown("![](https://evil.com/other.png)")).toEqual([]);
expect(
extractMediaUrlsFromMarkdown("![](https://evil.com/other.png)", {
allowedAssetOrigins: ["https://evil.com"],
}),
).toEqual([]);
});
});

66
server/utils/post-media-urls.ts

@ -2,18 +2,51 @@ import { POST_MEDIA_PUBLIC_PREFIX } from "../constants/media";
const MD_IMG_RE = /!\[[^\]]*]\(([^)]+)\)/g;
export type MergePostMediaUrlsOptions = {
/**
* origin URL `origin`
* URL `/public/assets/...`
*/
allowedAssetOrigins?: readonly string[];
};
function stripQueryHash(path: string): string {
return path.split("?")[0]?.split("#")[0] ?? path;
}
function buildAllowedOriginSet(entries: readonly string[] | undefined): Set<string> {
const s = new Set<string>();
if (!entries?.length) {
return s;
}
for (const e of entries) {
const t = e.trim();
if (!t) {
continue;
}
try {
s.add(new URL(t).origin);
} catch {
/* skip */
}
}
return s;
}
/** 统一为 `/public/assets/<key>`,供 `publicAssetUrlToStorageKey` 与引用同步使用。 */
function normalizeUrl(raw: string): string {
function normalizeUrl(raw: string, allowedOrigins: Set<string>): string {
const t = raw.trim().replace(/^<|>$/g, "").split(/\s+/)[0] ?? "";
if (t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {
return stripQueryHash(t);
}
if (allowedOrigins.size === 0) {
return "";
}
try {
const u = new URL(t);
if (!allowedOrigins.has(u.origin)) {
return "";
}
const path = stripQueryHash(u.pathname);
if (path.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {
return path;
@ -25,13 +58,14 @@ function normalizeUrl(raw: string): string {
}
/** 从 markdown 中提取本站图片 URL(去重,顺序稳定) */
export function extractMediaUrlsFromMarkdown(markdown: string): string[] {
export function extractMediaUrlsFromMarkdown(markdown: string, options?: MergePostMediaUrlsOptions): string[] {
const allowed = buildAllowedOriginSet(options?.allowedAssetOrigins);
const out: string[] = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
const re = new RegExp(MD_IMG_RE.source, MD_IMG_RE.flags);
while ((m = re.exec(markdown)) !== null) {
const u = normalizeUrl(m[1] ?? "");
const u = normalizeUrl(m[1] ?? "", allowed);
if (u && !seen.has(u)) {
seen.add(u);
out.push(u);
@ -40,17 +74,25 @@ export function extractMediaUrlsFromMarkdown(markdown: string): string[] {
return out;
}
export function extractMediaUrlsFromCover(coverUrl: string | null | undefined): string[] {
export function extractMediaUrlsFromCover(
coverUrl: string | null | undefined,
options?: MergePostMediaUrlsOptions,
): string[] {
if (!coverUrl) {
return [];
}
const u = normalizeUrl(coverUrl);
const allowed = buildAllowedOriginSet(options?.allowedAssetOrigins);
const u = normalizeUrl(coverUrl, allowed);
return u ? [u] : [];
}
export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null | undefined): string[] {
const a = extractMediaUrlsFromMarkdown(bodyMarkdown);
const b = extractMediaUrlsFromCover(coverUrl);
export function mergePostMediaUrls(
bodyMarkdown: string,
coverUrl: string | null | undefined,
options?: MergePostMediaUrlsOptions,
): string[] {
const a = extractMediaUrlsFromMarkdown(bodyMarkdown, options);
const b = extractMediaUrlsFromCover(coverUrl, options);
const seen = new Set<string>(a);
for (const u of b) {
if (!seen.has(u)) {
@ -61,8 +103,12 @@ export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null
return a;
}
export function mergeProfileMediaUrls(bioMarkdown: string | null | undefined, avatar: string | null | undefined): string[] {
return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null);
export function mergeProfileMediaUrls(
bioMarkdown: string | null | undefined,
avatar: string | null | undefined,
options?: MergePostMediaUrlsOptions,
): string[] {
return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null, options);
}
/** `/public/assets/foo.webp` → `foo.webp` */

Loading…
Cancel
Save