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 = { export type GlobalConfig = {
siteName: string siteName: string
allowRegister: boolean allowRegister: boolean
/** 与后台「站点对外地址」一致;空表示未配置 */
publicSiteUrl: string
} }
type GlobalConfigResult = { type GlobalConfigResult = {
@ -12,6 +14,7 @@ type GlobalConfigResult = {
const DEFAULT_GLOBAL_CONFIG: GlobalConfig = { const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
siteName: 'Person Panel', siteName: 'Person Panel',
allowRegister: true, allowRegister: true,
publicSiteUrl: '',
} }
export function useGlobalConfig() { export function useGlobalConfig() {

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

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

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

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession' import { useAuthSession } from '../../../composables/useAuthSession'
import { useGlobalConfig } from '../../../composables/useGlobalConfig'
definePageMeta({ title: '媒体库' }) definePageMeta({ title: '媒体库' })
@ -15,8 +16,22 @@ type MediaAssetRow = {
const toast = useToast() const toast = useToast()
const { refresh: refreshAuth } = useAuthSession() const { refresh: refreshAuth } = useAuthSession()
const { config, refresh: refreshGlobalConfig } = useGlobalConfig()
const { fetchData, getApiErrorMessage } = useClientApi() 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 page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const items = ref<MediaAssetRow[]>([]) const items = ref<MediaAssetRow[]>([])
@ -77,9 +92,10 @@ watch([page, pageSize], () => {
void load() void load()
}) })
onMounted(() => { onMounted(async () => {
void refreshAuth(true) await refreshAuth(true)
void load() await refreshGlobalConfig()
await load()
}) })
function openFilePicker() { function openFilePicker() {
@ -121,7 +137,7 @@ async function copyUrl(item: MediaAssetRow) {
if (!import.meta.client) { if (!import.meta.client) {
return return
} }
const absoluteUrl = `${window.location.origin}${item.publicPath}` const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
try { try {
await navigator.clipboard.writeText(absoluteUrl) await navigator.clipboard.writeText(absoluteUrl)
toast.add({ title: '已复制 URL', color: 'success' }) toast.add({ title: '已复制 URL', color: 'success' })
@ -134,7 +150,7 @@ async function copyMarkdown(item: MediaAssetRow) {
if (!import.meta.client) { if (!import.meta.client) {
return return
} }
const absoluteUrl = `${window.location.origin}${item.publicPath}` const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
const md = `![](${absoluteUrl})` const md = `![](${absoluteUrl})`
try { try {
await navigator.clipboard.writeText(md) await navigator.clipboard.writeText(md)

26
server/service/config/registry.ts

@ -31,6 +31,32 @@ const CONFIG_REGISTRY = {
defaultValue: true, defaultValue: true,
userOverridable: false, 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>({ mediaOrphanAutoSweepEnabled: defineConfig<boolean>({
key: "mediaOrphanAutoSweepEnabled", key: "mediaOrphanAutoSweepEnabled",
scope: "global", scope: "global",

23
server/service/media/index.ts

@ -10,6 +10,7 @@ import {
RELATIVE_ASSETS_DIR, RELATIVE_ASSETS_DIR,
} from "#server/constants/media"; } from "#server/constants/media";
import { MEDIA_REF_OWNER_POST, MEDIA_REF_OWNER_PROFILE } from "#server/constants/media-refs"; 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 { mergePostMediaUrls, mergeProfileMediaUrls, publicAssetUrlToStorageKey } from "#server/utils/post-media-urls";
import { nextIntegerId } from "#server/utils/sqlite-id"; 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( export async function syncPostMediaRefs(
userId: number, userId: number,
postId: number, postId: number,
@ -195,7 +212,8 @@ export async function syncPostMediaRefs(
await dbGlobal.delete(mediaRefs).where(postRef); 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))]; const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))];
let afterIds: number[] = []; let afterIds: number[] = [];
@ -233,7 +251,8 @@ export async function syncProfileMediaRefs(
await dbGlobal.delete(mediaRefs).where(profileRef); 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))]; const keys = [...new Set(urls.map((u) => publicAssetUrlToStorageKey(u)).filter((k): k is string => k != null))];
let afterIds: number[] = []; 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"; import { extractMediaUrlsFromMarkdown, publicAssetUrlToStorageKey } from "./post-media-urls";
describe("extractMediaUrlsFromMarkdown", () => { 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"]); 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( 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"]); ).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", () => { test("strips query on relative asset URL", () => {
expect(extractMediaUrlsFromMarkdown("![](/public/assets/c.webp?v=1)")).toEqual(["/public/assets/c.webp"]); expect(extractMediaUrlsFromMarkdown("![](/public/assets/c.webp?v=1)")).toEqual(["/public/assets/c.webp"]);
}); });
test("strips query on absolute asset URL via pathname", () => { test("strips query on absolute asset URL via pathname", () => {
expect( 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"]); ).toEqual(["/public/assets/d.webp"]);
}); });
test("ignores non-asset absolute URLs", () => { 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; const MD_IMG_RE = /!\[[^\]]*]\(([^)]+)\)/g;
export type MergePostMediaUrlsOptions = {
/**
* origin URL `origin`
* URL `/public/assets/...`
*/
allowedAssetOrigins?: readonly string[];
};
function stripQueryHash(path: string): string { function stripQueryHash(path: string): string {
return path.split("?")[0]?.split("#")[0] ?? path; 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` 与引用同步使用。 */ /** 统一为 `/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] ?? ""; const t = raw.trim().replace(/^<|>$/g, "").split(/\s+/)[0] ?? "";
if (t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) { if (t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {
return stripQueryHash(t); return stripQueryHash(t);
} }
if (allowedOrigins.size === 0) {
return "";
}
try { try {
const u = new URL(t); const u = new URL(t);
if (!allowedOrigins.has(u.origin)) {
return "";
}
const path = stripQueryHash(u.pathname); const path = stripQueryHash(u.pathname);
if (path.startsWith(POST_MEDIA_PUBLIC_PREFIX)) { if (path.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {
return path; return path;
@ -25,13 +58,14 @@ function normalizeUrl(raw: string): string {
} }
/** 从 markdown 中提取本站图片 URL(去重,顺序稳定) */ /** 从 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 out: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
const re = new RegExp(MD_IMG_RE.source, MD_IMG_RE.flags); const re = new RegExp(MD_IMG_RE.source, MD_IMG_RE.flags);
while ((m = re.exec(markdown)) !== null) { while ((m = re.exec(markdown)) !== null) {
const u = normalizeUrl(m[1] ?? ""); const u = normalizeUrl(m[1] ?? "", allowed);
if (u && !seen.has(u)) { if (u && !seen.has(u)) {
seen.add(u); seen.add(u);
out.push(u); out.push(u);
@ -40,17 +74,25 @@ export function extractMediaUrlsFromMarkdown(markdown: string): string[] {
return out; return out;
} }
export function extractMediaUrlsFromCover(coverUrl: string | null | undefined): string[] { export function extractMediaUrlsFromCover(
coverUrl: string | null | undefined,
options?: MergePostMediaUrlsOptions,
): string[] {
if (!coverUrl) { if (!coverUrl) {
return []; return [];
} }
const u = normalizeUrl(coverUrl); const allowed = buildAllowedOriginSet(options?.allowedAssetOrigins);
const u = normalizeUrl(coverUrl, allowed);
return u ? [u] : []; return u ? [u] : [];
} }
export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null | undefined): string[] { export function mergePostMediaUrls(
const a = extractMediaUrlsFromMarkdown(bodyMarkdown); bodyMarkdown: string,
const b = extractMediaUrlsFromCover(coverUrl); coverUrl: string | null | undefined,
options?: MergePostMediaUrlsOptions,
): string[] {
const a = extractMediaUrlsFromMarkdown(bodyMarkdown, options);
const b = extractMediaUrlsFromCover(coverUrl, options);
const seen = new Set<string>(a); const seen = new Set<string>(a);
for (const u of b) { for (const u of b) {
if (!seen.has(u)) { if (!seen.has(u)) {
@ -61,8 +103,12 @@ export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null
return a; return a;
} }
export function mergeProfileMediaUrls(bioMarkdown: string | null | undefined, avatar: string | null | undefined): string[] { export function mergeProfileMediaUrls(
return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null); bioMarkdown: string | null | undefined,
avatar: string | null | undefined,
options?: MergePostMediaUrlsOptions,
): string[] {
return mergePostMediaUrls(bioMarkdown ?? "", avatar ?? null, options);
} }
/** `/public/assets/foo.webp` → `foo.webp` */ /** `/public/assets/foo.webp` → `foo.webp` */

Loading…
Cancel
Save