Browse Source

refactor(site): use NUXT_PUBLIC_SITE_URL instead of DB publicSiteUrl

Made-with: Cursor
tags/邮箱功能前置
npmrun 3 weeks ago
parent
commit
5ae04d4fac
  1. 2
      .env.example
  2. 3
      app/composables/useGlobalConfig.ts
  3. 12
      app/pages/me/admin/config/index.vue
  4. 14
      app/pages/me/media/index.vue
  5. 6
      nuxt.config.ts
  6. 26
      server/service/config/registry.ts
  7. 22
      server/service/media/index.ts
  8. 26
      server/utils/site-public.test.ts
  9. 24
      server/utils/site-public.ts

2
.env.example

@ -1,6 +1,8 @@
# DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres # DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres
DATABASE_URL=file:./db.sqlite DATABASE_URL=file:./db.sqlite
NITRO_PORT=3399 NITRO_PORT=3399
# 站点对外根 URL(含协议与域名,可带端口)。用于:① 媒体库复制绝对链接 ② 文章/资料里绝对地址图片是否计为本站 /public/assets/ 引用。生产环境务必设置,与浏览器访问地址一致。
# 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). # 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= BOOTSTRAP_ADMIN_USERNAME=
BOOTSTRAP_ADMIN_PASSWORD= BOOTSTRAP_ADMIN_PASSWORD=

3
app/composables/useGlobalConfig.ts

@ -3,8 +3,6 @@ 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 = {
@ -14,7 +12,6 @@ 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,7 +7,6 @@ type GlobalConfigPayload = {
config: { config: {
siteName: string siteName: string
allowRegister: boolean allowRegister: boolean
publicSiteUrl: string
mediaOrphanAutoSweepEnabled: boolean mediaOrphanAutoSweepEnabled: boolean
mediaOrphanAutoSweepIntervalMinutes: number mediaOrphanAutoSweepIntervalMinutes: number
} }
@ -22,7 +21,6 @@ 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)
@ -39,7 +37,6 @@ 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 {
@ -64,7 +61,6 @@ 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()
@ -82,7 +78,7 @@ async function save() {
应用配置 应用配置
</h1> </h1>
<p class="text-sm text-muted"> <p class="text-sm text-muted">
全局设置站点名称对外访问地址开放注册与媒体孤儿自动清扫 全局设置站点名称开放注册与媒体孤儿自动清扫
</p> </p>
<UCard> <UCard>
@ -103,12 +99,6 @@ 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="全站生效、仅删除已过宽限期且无引用的图片、建议先在「图片孤儿审查」确认。"
> >

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

@ -1,6 +1,5 @@
<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: '媒体库' })
@ -16,12 +15,12 @@ type MediaAssetRow = {
const toast = useToast() const toast = useToast()
const { refresh: refreshAuth } = useAuthSession() const { refresh: refreshAuth } = useAuthSession()
const { config, refresh: refreshGlobalConfig } = useGlobalConfig() const runtimeConfig = useRuntimeConfig()
const { fetchData, getApiErrorMessage } = useClientApi() const { fetchData, getApiErrorMessage } = useClientApi()
/** 与全局「站点对外地址」一致时复制出的绝对链接才会被服务端计入引用;未配置则退回当前页 origin。 */ /** 与 `NUXT_PUBLIC_SITE_URL` / `runtimeConfig.public.siteUrl` 一致;未配置则退回当前页 origin。 */
function resolveCopyOrigin(): string { function resolveCopyOrigin(): string {
const raw = config.value.publicSiteUrl?.trim() ?? '' const raw = String(runtimeConfig.public.siteUrl ?? '').trim()
if (raw) { if (raw) {
try { try {
return new URL(raw).origin return new URL(raw).origin
@ -92,10 +91,9 @@ watch([page, pageSize], () => {
void load() void load()
}) })
onMounted(async () => { onMounted(() => {
await refreshAuth(true) void refreshAuth(true)
await refreshGlobalConfig() void load()
await load()
}) })
function openFilePicker() { function openFilePicker() {

6
nuxt.config.ts

@ -26,6 +26,12 @@ function resolveNuxtNitroErrorHandler(): string {
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
/** 部署时设置 `NUXT_PUBLIC_SITE_URL`(如 https://blog.example.com)可自动覆盖;用于媒体绝对链接与引用同步。 */
runtimeConfig: {
public: {
siteUrl: '',
},
},
modules: ['@nuxt/ui'], modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
ui: { ui: {

26
server/service/config/registry.ts

@ -31,32 +31,6 @@ 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",

22
server/service/media/index.ts

@ -10,8 +10,8 @@ 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 { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public";
import { nextIntegerId } from "#server/utils/sqlite-id"; import { nextIntegerId } from "#server/utils/sqlite-id";
const NEVER_REF_MS = MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF * 3600 * 1000; const NEVER_REF_MS = MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF * 3600 * 1000;
@ -184,22 +184,6 @@ 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,
@ -212,7 +196,7 @@ export async function syncPostMediaRefs(
await dbGlobal.delete(mediaRefs).where(postRef); await dbGlobal.delete(mediaRefs).where(postRef);
const allowedOrigins = await allowedAssetOriginsForRefs(); const allowedOrigins = allowedOriginsFromSitePublicEnv();
const urls = mergePostMediaUrls(bodyMarkdown, coverUrl, { allowedAssetOrigins: allowedOrigins }); 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))];
@ -251,7 +235,7 @@ export async function syncProfileMediaRefs(
await dbGlobal.delete(mediaRefs).where(profileRef); await dbGlobal.delete(mediaRefs).where(profileRef);
const allowedOrigins = await allowedAssetOriginsForRefs(); const allowedOrigins = allowedOriginsFromSitePublicEnv();
const urls = mergeProfileMediaUrls(bioMarkdown, avatar, { allowedAssetOrigins: allowedOrigins }); 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))];

26
server/utils/site-public.test.ts

@ -0,0 +1,26 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { allowedOriginsFromSitePublicEnv, getSitePublicUrlFromEnv } from "./site-public";
describe("site-public env", () => {
const prev = process.env.NUXT_PUBLIC_SITE_URL;
afterEach(() => {
if (prev === undefined) {
delete process.env.NUXT_PUBLIC_SITE_URL;
} else {
process.env.NUXT_PUBLIC_SITE_URL = prev;
}
});
test("empty when env unset", () => {
delete process.env.NUXT_PUBLIC_SITE_URL;
expect(getSitePublicUrlFromEnv()).toBe("");
expect(allowedOriginsFromSitePublicEnv()).toEqual([]);
});
test("parses https origin", () => {
process.env.NUXT_PUBLIC_SITE_URL = "https://blog.example.com/posts";
expect(getSitePublicUrlFromEnv()).toBe("https://blog.example.com/posts");
expect(allowedOriginsFromSitePublicEnv()).toEqual(["https://blog.example.com"]);
});
});

24
server/utils/site-public.ts

@ -0,0 +1,24 @@
/**
* Nuxt `runtimeConfig.public.siteUrl` **`NUXT_PUBLIC_SITE_URL`**
*/ `.env.example`
*/
export function getSitePublicUrlFromEnv(): string {
return (process.env.NUXT_PUBLIC_SITE_URL ?? "").trim();
}
/** 用于 `mergePostMediaUrls` 的 `allowedAssetOrigins`;无效或空则返回空数组。 */
export function allowedOriginsFromSitePublicEnv(): string[] {
const raw = getSitePublicUrlFromEnv();
if (!raw) {
return [];
}
try {
const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") {
return [];
}
return [u.origin];
} catch {
return [];
}
}
Loading…
Cancel
Save