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=file:./db.sqlite
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).
BOOTSTRAP_ADMIN_USERNAME=
BOOTSTRAP_ADMIN_PASSWORD=

3
app/composables/useGlobalConfig.ts

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

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

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

6
nuxt.config.ts

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

26
server/service/config/registry.ts

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

22
server/service/media/index.ts

@ -10,8 +10,8 @@ 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 { allowedOriginsFromSitePublicEnv } from "#server/utils/site-public";
import { nextIntegerId } from "#server/utils/sqlite-id";
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(
userId: number,
postId: number,
@ -212,7 +196,7 @@ export async function syncPostMediaRefs(
await dbGlobal.delete(mediaRefs).where(postRef);
const allowedOrigins = await allowedAssetOriginsForRefs();
const allowedOrigins = allowedOriginsFromSitePublicEnv();
const urls = mergePostMediaUrls(bodyMarkdown, coverUrl, { allowedAssetOrigins: allowedOrigins });
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);
const allowedOrigins = await allowedAssetOriginsForRefs();
const allowedOrigins = allowedOriginsFromSitePublicEnv();
const urls = mergeProfileMediaUrls(bioMarkdown, avatar, { allowedAssetOrigins: allowedOrigins });
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