新增底部导航链接配置与 Favicon 上传

- nav_links 增加 position(header/footer,迁移 v11),后台“导航链接”拆为头部/底部两张卡片,页脚链接支持多语言、新窗口与点分隔,删除硬编码的关于我们等链接
- site_settings 增加 favicon(迁移 v12),新增 PUT/DELETE /api/site/favicon,支持 ICO/PNG/SVG 等且不裁剪,引用计数与 Logo 一致自动管理
- 文件服务识别 SVG 并允许内联,统一附加 CSP(default-src 'none'; sandbox)防止存储型 XSS;Logo 维持仅栅格
- 前端 SiteInfoForm 增加 Favicon 上传/清空/预览,App.vue 动态更新 link rel=icon,三语文案补齐
- 补充位置与 Favicon 的接口/引用计数/安全头测试,重新生成 Swagger 文档
This commit is contained in:
2026-09-21 23:23:11 +08:00
parent 5b749ea365
commit 588b63a15b
29 files changed
+1264 -279

No files matched your search

+137 -9
View File
@@ -540,7 +540,7 @@ const docTemplate = `{
},
"/nav-links": {
"get": {
"description": "Public header navigation links (status enabled), ordered by sort ASC then id ASC, with translations.",
"description": "Public site navigation links (header and footer, status enabled), ordered by sort ASC then id ASC, with translations and position.",
"produces": [
"application/json"
],
@@ -572,7 +572,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Admin only. Create a header navigation link. url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.",
"description": "Admin only. Create a site navigation link (position: header or footer, defaults to header). url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.",
"consumes": [
"application/json"
],
@@ -635,7 +635,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Admin only. List all header navigation links including disabled ones, with translations.",
"description": "Admin only. List all site navigation links including disabled ones, with translations.",
"produces": [
"application/json"
],
@@ -681,7 +681,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Admin only. Update a header navigation link; translations are replaced by the provided list.",
"description": "Admin only. Update a site navigation link; position is kept when omitted; translations are replaced by the provided list.",
"consumes": [
"application/json"
],
@@ -756,7 +756,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Admin only. Delete a header navigation link and its translations.",
"description": "Admin only. Delete a site navigation link and its translations.",
"produces": [
"application/json"
],
@@ -1133,7 +1133,7 @@ const docTemplate = `{
},
"/site": {
"get": {
"description": "Public site settings: site name, logo URL, and footer text. Returns built-in defaults when the settings row is missing.",
"description": "Public site settings: site name, logo URL, favicon URL, and footer text. Returns built-in defaults when the settings row is missing.",
"produces": [
"application/json"
],
@@ -1162,7 +1162,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Admin only. Update the site name, logo URL, and footer text; logo files hosted on this site have their reference count managed automatically. Returns the updated settings.",
"description": "Admin only. Update the site name, logo URL, favicon URL, and footer text; logo and favicon files hosted on this site have their reference count managed automatically. Returns the updated settings.",
"consumes": [
"application/json"
],
@@ -1192,7 +1192,7 @@ const docTemplate = `{
}
},
"400": {
"description": "invalid request or logo file not found",
"description": "invalid request or file not found",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
@@ -1218,6 +1218,114 @@ const docTemplate = `{
}
}
},
"/site/favicon": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Admin only. Upload an image (raster or SVG) as the site favicon (multipart field file); takes effect immediately and file references are managed automatically. SVG is served inline with a restrictive Content-Security-Policy.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Upload site favicon",
"parameters": [
{
"type": "file",
"description": "Favicon image",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.SiteSetting"
}
},
"400": {
"description": "invalid request, empty file, or not an image",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "admin permission required or account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"413": {
"description": "file too large",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Admin only. Clear the site favicon and release the reference of the locally stored favicon file.",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Delete site favicon",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.SiteSetting"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "admin permission required or account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/site/logo": {
"put": {
"security": [
@@ -1225,7 +1333,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Admin only. Upload an image as the site logo (multipart field file, image only); the logo takes effect immediately and file references are managed automatically.",
"description": "Admin only. Upload a raster image as the site logo (multipart field file); the logo takes effect immediately and file references are managed automatically.",
"consumes": [
"multipart/form-data"
],
@@ -2154,6 +2262,9 @@ const docTemplate = `{
"open_in_new_window": {
"type": "boolean"
},
"position": {
"type": "string"
},
"sort": {
"type": "integer"
},
@@ -2211,6 +2322,10 @@ const docTemplate = `{
"model.SiteSetting": {
"type": "object",
"properties": {
"favicon": {
"type": "string",
"example": "https://example.com/favicon.ico"
},
"footer": {
"type": "string",
"example": "Copyright © Rill"
@@ -2305,6 +2420,14 @@ const docTemplate = `{
"type": "boolean",
"example": false
},
"position": {
"type": "string",
"enum": [
"header",
"footer"
],
"example": "header"
},
"sort": {
"type": "integer",
"example": 0
@@ -2396,6 +2519,11 @@ const docTemplate = `{
"site_name"
],
"properties": {
"favicon": {
"type": "string",
"maxLength": 500,
"example": "https://example.com/favicon.ico"
},
"footer": {
"type": "string",
"maxLength": 1000,
+137 -9
View File
@@ -533,7 +533,7 @@
},
"/nav-links": {
"get": {
"description": "Public header navigation links (status enabled), ordered by sort ASC then id ASC, with translations.",
"description": "Public site navigation links (header and footer, status enabled), ordered by sort ASC then id ASC, with translations and position.",
"produces": [
"application/json"
],
@@ -565,7 +565,7 @@
"BearerAuth": []
}
],
"description": "Admin only. Create a header navigation link. url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.",
"description": "Admin only. Create a site navigation link (position: header or footer, defaults to header). url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.",
"consumes": [
"application/json"
],
@@ -628,7 +628,7 @@
"BearerAuth": []
}
],
"description": "Admin only. List all header navigation links including disabled ones, with translations.",
"description": "Admin only. List all site navigation links including disabled ones, with translations.",
"produces": [
"application/json"
],
@@ -674,7 +674,7 @@
"BearerAuth": []
}
],
"description": "Admin only. Update a header navigation link; translations are replaced by the provided list.",
"description": "Admin only. Update a site navigation link; position is kept when omitted; translations are replaced by the provided list.",
"consumes": [
"application/json"
],
@@ -749,7 +749,7 @@
"BearerAuth": []
}
],
"description": "Admin only. Delete a header navigation link and its translations.",
"description": "Admin only. Delete a site navigation link and its translations.",
"produces": [
"application/json"
],
@@ -1126,7 +1126,7 @@
},
"/site": {
"get": {
"description": "Public site settings: site name, logo URL, and footer text. Returns built-in defaults when the settings row is missing.",
"description": "Public site settings: site name, logo URL, favicon URL, and footer text. Returns built-in defaults when the settings row is missing.",
"produces": [
"application/json"
],
@@ -1155,7 +1155,7 @@
"BearerAuth": []
}
],
"description": "Admin only. Update the site name, logo URL, and footer text; logo files hosted on this site have their reference count managed automatically. Returns the updated settings.",
"description": "Admin only. Update the site name, logo URL, favicon URL, and footer text; logo and favicon files hosted on this site have their reference count managed automatically. Returns the updated settings.",
"consumes": [
"application/json"
],
@@ -1185,7 +1185,7 @@
}
},
"400": {
"description": "invalid request or logo file not found",
"description": "invalid request or file not found",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
@@ -1211,6 +1211,114 @@
}
}
},
"/site/favicon": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Admin only. Upload an image (raster or SVG) as the site favicon (multipart field file); takes effect immediately and file references are managed automatically. SVG is served inline with a restrictive Content-Security-Policy.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Upload site favicon",
"parameters": [
{
"type": "file",
"description": "Favicon image",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.SiteSetting"
}
},
"400": {
"description": "invalid request, empty file, or not an image",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "admin permission required or account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"413": {
"description": "file too large",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Admin only. Clear the site favicon and release the reference of the locally stored favicon file.",
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Delete site favicon",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.SiteSetting"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "admin permission required or account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/site/logo": {
"put": {
"security": [
@@ -1218,7 +1326,7 @@
"BearerAuth": []
}
],
"description": "Admin only. Upload an image as the site logo (multipart field file, image only); the logo takes effect immediately and file references are managed automatically.",
"description": "Admin only. Upload a raster image as the site logo (multipart field file); the logo takes effect immediately and file references are managed automatically.",
"consumes": [
"multipart/form-data"
],
@@ -2147,6 +2255,9 @@
"open_in_new_window": {
"type": "boolean"
},
"position": {
"type": "string"
},
"sort": {
"type": "integer"
},
@@ -2204,6 +2315,10 @@
"model.SiteSetting": {
"type": "object",
"properties": {
"favicon": {
"type": "string",
"example": "https://example.com/favicon.ico"
},
"footer": {
"type": "string",
"example": "Copyright © Rill"
@@ -2298,6 +2413,14 @@
"type": "boolean",
"example": false
},
"position": {
"type": "string",
"enum": [
"header",
"footer"
],
"example": "header"
},
"sort": {
"type": "integer",
"example": 0
@@ -2389,6 +2512,11 @@
"site_name"
],
"properties": {
"favicon": {
"type": "string",
"maxLength": 500,
"example": "https://example.com/favicon.ico"
},
"footer": {
"type": "string",
"maxLength": 1000,
+105 -19
View File
@@ -113,6 +113,8 @@ definitions:
type: integer
open_in_new_window:
type: boolean
position:
type: string
sort:
type: integer
status:
@@ -150,6 +152,9 @@ definitions:
type: object
model.SiteSetting:
properties:
favicon:
example: https://example.com/favicon.ico
type: string
footer:
example: Copyright © Rill
type: string
@@ -211,6 +216,12 @@ definitions:
open_in_new_window:
example: false
type: boolean
position:
enum:
- header
- footer
example: header
type: string
sort:
example: 0
type: integer
@@ -277,6 +288,10 @@ definitions:
type: object
site.UpdateRequest:
properties:
favicon:
example: https://example.com/favicon.ico
maxLength: 500
type: string
footer:
example: Copyright © Rill
maxLength: 1000
@@ -788,8 +803,8 @@ paths:
- user
/nav-links:
get:
description: Public header navigation links (status enabled), ordered by sort
ASC then id ASC, with translations.
description: Public site navigation links (header and footer, status enabled),
ordered by sort ASC then id ASC, with translations and position.
produces:
- application/json
responses:
@@ -809,9 +824,9 @@ paths:
post:
consumes:
- application/json
description: Admin only. Create a header navigation link. url accepts site-relative
paths (/...), http(s) URLs, and mailto links; translations must include at
least one non-empty label.
description: 'Admin only. Create a site navigation link (position: header or
footer, defaults to header). url accepts site-relative paths (/...), http(s)
URLs, and mailto links; translations must include at least one non-empty label.'
parameters:
- description: Nav link payload
in: body
@@ -849,7 +864,7 @@ paths:
- admin
/nav-links/{id}:
delete:
description: Admin only. Delete a header navigation link and its translations.
description: Admin only. Delete a site navigation link and its translations.
parameters:
- description: Nav link ID
example: 1
@@ -890,8 +905,8 @@ paths:
put:
consumes:
- application/json
description: Admin only. Update a header navigation link; translations are replaced
by the provided list.
description: Admin only. Update a site navigation link; position is kept when
omitted; translations are replaced by the provided list.
parameters:
- description: Nav link ID
example: 1
@@ -939,8 +954,8 @@ paths:
- admin
/nav-links/list:
get:
description: Admin only. List all header navigation links including disabled
ones, with translations.
description: Admin only. List all site navigation links including disabled ones,
with translations.
produces:
- application/json
responses:
@@ -1177,8 +1192,8 @@ paths:
- user
/site:
get:
description: 'Public site settings: site name, logo URL, and footer text. Returns
built-in defaults when the settings row is missing.'
description: 'Public site settings: site name, logo URL, favicon URL, and footer
text. Returns built-in defaults when the settings row is missing.'
produces:
- application/json
responses:
@@ -1196,9 +1211,9 @@ paths:
put:
consumes:
- application/json
description: Admin only. Update the site name, logo URL, and footer text; logo
files hosted on this site have their reference count managed automatically.
Returns the updated settings.
description: Admin only. Update the site name, logo URL, favicon URL, and footer
text; logo and favicon files hosted on this site have their reference count
managed automatically. Returns the updated settings.
parameters:
- description: Site settings
in: body
@@ -1214,7 +1229,7 @@ paths:
schema:
$ref: '#/definitions/model.SiteSetting'
"400":
description: invalid request or logo file not found
description: invalid request or file not found
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"401":
@@ -1234,6 +1249,78 @@ paths:
summary: Update site settings
tags:
- admin
/site/favicon:
delete:
description: Admin only. Clear the site favicon and release the reference of
the locally stored favicon file.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.SiteSetting'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: admin permission required or account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Delete site favicon
tags:
- admin
put:
consumes:
- multipart/form-data
description: Admin only. Upload an image (raster or SVG) as the site favicon
(multipart field file); takes effect immediately and file references are managed
automatically. SVG is served inline with a restrictive Content-Security-Policy.
parameters:
- description: Favicon image
in: formData
name: file
required: true
type: file
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.SiteSetting'
"400":
description: invalid request, empty file, or not an image
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: admin permission required or account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"413":
description: file too large
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Upload site favicon
tags:
- admin
/site/logo:
delete:
description: Admin only. Clear the site logo and release the reference of the
@@ -1265,9 +1352,8 @@ paths:
put:
consumes:
- multipart/form-data
description: Admin only. Upload an image as the site logo (multipart field file,
image only); the logo takes effect immediately and file references are managed
automatically.
description: Admin only. Upload a raster image as the site logo (multipart field
file); the logo takes effect immediately and file references are managed automatically.
parameters:
- description: Logo image
in: formData
+14
View File
@@ -15,6 +15,20 @@ watch(
},
{ immediate: true },
)
watch(
() => site.favicon,
(favicon) => {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = favicon || '/favicon.ico'
},
{ immediate: true },
)
</script>
<template>
+2
View File
@@ -8,6 +8,7 @@ export interface NavTranslation {
export interface NavLink {
id: number
url: string
position: string
open_in_new_window: boolean
sort: number
status: number
@@ -16,6 +17,7 @@ export interface NavLink {
export interface NavLinkPayload {
url: string
position: string
open_in_new_window: boolean
sort: number
status: number
+17 -2
View File
@@ -3,6 +3,7 @@ import { request } from './http'
export interface SiteInfo {
site_name: string
logo: string
favicon: string
footer: string
updated_at: string
}
@@ -10,6 +11,7 @@ export interface SiteInfo {
export interface UpdateSitePayload {
site_name: string
logo: string
favicon: string
footer: string
}
@@ -24,9 +26,9 @@ export function updateSiteInfo(payload: UpdateSitePayload): Promise<SiteInfo> {
})
}
export function uploadSiteLogo(file: Blob, filename = 'logo.png'): Promise<SiteInfo> {
export function uploadSiteLogo(file: File): Promise<SiteInfo> {
const form = new FormData()
form.append('file', file, filename)
form.append('file', file, file.name)
return request<SiteInfo>('/site/logo', {
method: 'PUT',
body: form,
@@ -36,3 +38,16 @@ export function uploadSiteLogo(file: Blob, filename = 'logo.png'): Promise<SiteI
export function deleteSiteLogo(): Promise<SiteInfo> {
return request<SiteInfo>('/site/logo', { method: 'DELETE' })
}
export function uploadSiteFavicon(file: File): Promise<SiteInfo> {
const form = new FormData()
form.append('file', file, file.name)
return request<SiteInfo>('/site/favicon', {
method: 'PUT',
body: form,
})
}
export function deleteSiteFavicon(): Promise<SiteInfo> {
return request<SiteInfo>('/site/favicon', { method: 'DELETE' })
}
+27 -11
View File
@@ -16,6 +16,10 @@ import { useNavStore } from '@/stores/nav'
const { t } = useI18n()
const nav = useNavStore()
const props = defineProps<{
position: 'header' | 'footer'
}>()
const links = ref<NavLink[]>([])
const loading = ref(false)
const busy = ref(false)
@@ -24,6 +28,15 @@ const notice = ref('')
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const isFooter = computed(() => props.position === 'footer')
const cardTitle = computed(() =>
isFooter.value ? t('admin.nav.footerTitle') : t('admin.nav.headerTitle'),
)
const cardSubtitle = computed(() =>
isFooter.value ? t('admin.nav.footerSubtitle') : t('admin.nav.headerSubtitle'),
)
const positionLinks = computed(() => links.value.filter((link) => link.position === props.position))
interface FormState {
labels: Record<string, string>
url: string
@@ -69,7 +82,9 @@ function resetForm() {
form.labels = emptyLabels()
form.url = ''
form.openInNewWindow = false
form.sort = links.value.length ? Math.max(...links.value.map((item) => item.sort)) + 10 : 10
form.sort = positionLinks.value.length
? Math.max(...positionLinks.value.map((item) => item.sort)) + 10
: 10
form.status = 1
editingId.value = null
error.value = ''
@@ -130,6 +145,7 @@ async function submit() {
const payload: NavLinkPayload = {
url: form.url.trim(),
position: props.position,
open_in_new_window: form.openInNewWindow,
sort: Number.isFinite(form.sort) ? form.sort : 0,
status: form.status,
@@ -191,8 +207,8 @@ function linkTitle(link: NavLink): string {
<section class="rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line sm:p-8">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold">{{ t('admin.nav.title') }}</h2>
<p class="mt-1 text-sm text-content-3">{{ t('admin.nav.subtitle') }}</p>
<h2 class="text-base font-semibold">{{ cardTitle }}</h2>
<p class="mt-1 text-sm text-content-3">{{ cardSubtitle }}</p>
</div>
<button
type="button"
@@ -214,9 +230,9 @@ function linkTitle(link: NavLink): string {
{{ notice }}
</p>
<ul v-if="links.length" class="mt-4 divide-y divide-line rounded-xl border border-line">
<ul v-if="positionLinks.length" class="mt-4 divide-y divide-line rounded-xl border border-line">
<li
v-for="link in links"
v-for="link in positionLinks"
:key="link.id"
class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3"
>
@@ -269,13 +285,13 @@ function linkTitle(link: NavLink): string {
<div class="mt-4 grid gap-4 sm:grid-cols-3">
<div v-for="item in SUPPORTED_LOCALES" :key="item.code">
<label
:for="`nav-label-${item.code}`"
:for="`nav-label-${position}-${item.code}`"
class="mb-1.5 block text-sm text-content-2"
>
{{ t('admin.nav.labels') }} · {{ item.label }}
</label>
<input
:id="`nav-label-${item.code}`"
:id="`nav-label-${position}-${item.code}`"
v-model="form.labels[item.code]"
type="text"
maxlength="100"
@@ -286,11 +302,11 @@ function linkTitle(link: NavLink): string {
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="nav-url" class="mb-1.5 block text-sm text-content-2">
<label :for="`nav-url-${position}`" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.nav.url') }}
</label>
<input
id="nav-url"
:id="`nav-url-${position}`"
v-model="form.url"
type="text"
maxlength="512"
@@ -299,11 +315,11 @@ function linkTitle(link: NavLink): string {
/>
</div>
<div>
<label for="nav-sort" class="mb-1.5 block text-sm text-content-2">
<label :for="`nav-sort-${position}`" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.nav.sort') }}
</label>
<input
id="nav-sort"
:id="`nav-sort-${position}`"
v-model.number="form.sort"
type="number"
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm outline-none transition-colors focus:border-primary sm:max-w-40"
@@ -10,6 +10,7 @@ import { useSiteStore } from '@/stores/site'
interface SiteForm {
siteName: string
logo: string
favicon: string
footer: string
}
@@ -24,6 +25,7 @@ const validationSchema = computed(() =>
.min(1, t('admin.errors.siteNameRequired'))
.max(100, t('admin.errors.siteNameLength')),
logo: z.string().max(500, t('admin.errors.logoLength')),
favicon: z.string().max(500, t('admin.errors.faviconLength')),
footer: z.string().max(1000, t('admin.errors.footerLength')),
}),
),
@@ -34,12 +36,14 @@ const { defineField, handleSubmit, errors, isSubmitting, setValues } = useForm<S
initialValues: {
siteName: site.siteName,
logo: site.logo,
favicon: site.favicon,
footer: site.footer,
},
})
const [siteName, siteNameProps] = defineField('siteName')
const [logo, logoProps] = defineField('logo')
const [favicon, faviconProps] = defineField('favicon')
const [footer, footerProps] = defineField('footer')
const submitError = ref('')
@@ -110,11 +114,84 @@ async function onLogoClear() {
}
}
const faviconInput = ref<HTMLInputElement | null>(null)
const faviconBusy = ref(false)
const faviconError = ref('')
const faviconNotice = ref('')
const MAX_FAVICON_MB = 10
const faviconPreview = computed(() => favicon.value.trim())
function resetFaviconInput() {
if (faviconInput.value) {
faviconInput.value.value = ''
}
}
function isFaviconFile(file: File): boolean {
if (file.type.startsWith('image/')) {
return true
}
return /\.(ico|svg)$/i.test(file.name)
}
async function onFaviconSelected(event: Event) {
faviconError.value = ''
faviconNotice.value = ''
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) {
return
}
if (!isFaviconFile(file)) {
faviconError.value = t('admin.errors.faviconType')
resetFaviconInput()
return
}
if (file.size > MAX_FAVICON_MB * 1024 * 1024) {
faviconError.value = t('admin.errors.faviconSize', { size: MAX_FAVICON_MB })
resetFaviconInput()
return
}
faviconBusy.value = true
try {
await site.uploadFavicon(file)
favicon.value = site.favicon
faviconNotice.value = t('admin.faviconUploadSuccess')
} catch (error) {
if (error instanceof ApiError && (error.status === 400 || error.status === 413)) {
faviconError.value = t('admin.errors.faviconInvalid')
} else {
faviconError.value = t('admin.errors.faviconUpload')
}
} finally {
faviconBusy.value = false
resetFaviconInput()
}
}
async function onFaviconClear() {
faviconError.value = ''
faviconNotice.value = ''
faviconBusy.value = true
try {
await site.removeFavicon()
favicon.value = site.favicon
faviconNotice.value = t('admin.faviconClearSuccess')
} catch {
faviconError.value = t('admin.errors.faviconUpload')
} finally {
faviconBusy.value = false
}
}
onMounted(async () => {
await site.load()
setValues({
siteName: site.siteName,
logo: site.logo,
favicon: site.favicon,
footer: site.footer,
})
})
@@ -126,6 +203,7 @@ const onSubmit = handleSubmit(async (values) => {
await site.save({
site_name: values.siteName,
logo: values.logo,
favicon: values.favicon,
footer: values.footer,
})
saved.value = true
@@ -232,6 +310,72 @@ const onSubmit = handleSubmit(async (values) => {
<p class="mt-2 text-xs text-content-3">{{ t('admin.logoHint') }}</p>
</div>
<div class="mt-4">
<label for="site-favicon" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.favicon') }}
</label>
<input
id="site-favicon"
v-model="favicon"
v-bind="faviconProps"
type="text"
maxlength="500"
:placeholder="t('admin.faviconPlaceholder')"
class="h-10 w-full rounded-lg border bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
:class="errors.favicon ? 'border-red-500' : 'border-line'"
/>
<p v-if="errors.favicon" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.favicon }}
</p>
<div class="mt-3 flex items-center gap-3">
<span class="text-xs text-content-3">{{ t('admin.faviconPreview') }}</span>
<img
v-if="faviconPreview"
:src="faviconPreview"
alt=""
class="h-10 w-10 rounded-lg object-contain ring-1 ring-line"
/>
<img
v-else
src="/favicon.ico"
alt=""
class="h-10 w-10 rounded-lg object-contain opacity-60 ring-1 ring-line"
/>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
:disabled="faviconBusy"
class="h-9 rounded-full border border-primary px-4 text-sm text-primary transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-60"
@click="faviconInput?.click()"
>
{{ faviconBusy ? t('common.submitting') : t('admin.faviconUpload') }}
</button>
<button
v-if="faviconPreview"
type="button"
:disabled="faviconBusy"
class="h-9 rounded-full border border-line px-4 text-sm text-content-2 transition-colors hover:border-red-500 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
@click="onFaviconClear"
>
{{ t('admin.faviconClear') }}
</button>
<input
ref="faviconInput"
type="file"
accept="image/*,.ico,.svg"
class="hidden"
@change="onFaviconSelected"
/>
</div>
<p v-if="faviconError" class="mt-2 text-xs text-red-500 dark:text-red-400">
{{ faviconError }}
</p>
<p v-else-if="faviconNotice" class="mt-2 text-xs text-primary">{{ faviconNotice }}</p>
<p class="mt-2 text-xs text-content-3">{{ t('admin.faviconHint') }}</p>
</div>
<div class="mt-4">
<label for="site-footer" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.footer') }}
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import LanguageSwitcher from '@/components/layout/LanguageSwitcher.vue'
import ThemeSwitcher from '@/components/layout/ThemeSwitcher.vue'
import { isInternalNavUrl, navLabel, useNavStore } from '@/stores/nav'
import { useSiteStore } from '@/stores/site'
const { t, locale } = useI18n()
const nav = useNavStore()
const site = useSiteStore()
</script>
<template>
<footer class="rounded-xl bg-surface px-6 py-6 text-center text-xs text-content-3">
<div class="mb-4 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<ThemeSwitcher />
<LanguageSwitcher />
</div>
<nav
v-if="nav.footerLinks.length"
class="mb-3 flex flex-wrap items-center justify-center gap-x-2 gap-y-2"
>
<template v-for="(link, index) in nav.footerLinks" :key="link.id">
<span v-if="index > 0" aria-hidden="true" class="text-content-3">·</span>
<RouterLink
v-if="isInternalNavUrl(link.url)"
:to="link.url"
:target="link.open_in_new_window ? '_blank' : undefined"
:rel="link.open_in_new_window ? 'noopener noreferrer' : undefined"
class="hover:text-primary"
>
{{ navLabel(link, locale) }}
</RouterLink>
<a
v-else
:href="link.url"
:target="link.open_in_new_window ? '_blank' : undefined"
:rel="link.open_in_new_window ? 'noopener noreferrer' : undefined"
class="hover:text-primary"
>
{{ navLabel(link, locale) }}
</a>
</template>
</nav>
<p>{{ site.footer || t('footer.copyright') }}</p>
</footer>
</template>
+6 -20
View File
@@ -2,9 +2,8 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import type { NavLink } from '@/api/nav'
import { useAuthStore } from '@/stores/auth'
import { useNavStore } from '@/stores/nav'
import { isInternalNavUrl, navLabel, useNavStore } from '@/stores/nav'
import { useSiteStore } from '@/stores/site'
const { t, locale } = useI18n()
@@ -18,19 +17,6 @@ const keyword = ref('')
const menuOpen = ref(false)
const menuRef = ref<HTMLElement | null>(null)
function isInternal(url: string): boolean {
return url.startsWith('/')
}
function navLabel(link: NavLink): string {
const current = link.translations.find((item) => item.locale === locale.value)?.label
if (current) {
return current
}
const fallback = link.translations.find((item) => item.locale === 'zh-CN')?.label
return fallback ?? link.translations[0]?.label ?? ''
}
function onDocumentClick(event: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
menuOpen.value = false
@@ -63,16 +49,16 @@ function logout() {
<span>{{ site.name }}</span>
</a>
<nav v-if="nav.hasLinks" class="hidden items-center gap-5 text-sm lg:flex">
<template v-for="link in nav.links" :key="link.id">
<nav v-if="nav.headerLinks.length" class="hidden items-center gap-5 text-sm lg:flex">
<template v-for="link in nav.headerLinks" :key="link.id">
<RouterLink
v-if="isInternal(link.url)"
v-if="isInternalNavUrl(link.url)"
:to="link.url"
:target="link.open_in_new_window ? '_blank' : undefined"
:rel="link.open_in_new_window ? 'noopener noreferrer' : undefined"
class="font-medium text-content-1 transition-colors hover:text-primary"
>
{{ navLabel(link) }}
{{ navLabel(link, locale) }}
</RouterLink>
<a
v-else
@@ -81,7 +67,7 @@ function logout() {
:rel="link.open_in_new_window ? 'noopener noreferrer' : undefined"
class="font-medium text-content-1 transition-colors hover:text-primary"
>
{{ navLabel(link) }}
{{ navLabel(link, locale) }}
</a>
</template>
</nav>
+19 -11
View File
@@ -39,14 +39,6 @@ const enUS: MessageSchema = {
video: 'Videos',
},
footer: {
links: {
about: 'About Us',
help: 'Help Center',
terms: 'Terms of Service',
privacy: 'Privacy Policy',
guidelines: 'Community Guidelines',
contact: 'Contact Us',
},
copyright: 'Rill streaming platform · Page frame placeholder · All images are blank placeholders',
},
language: {
@@ -138,7 +130,7 @@ const enUS: MessageSchema = {
},
admin: {
title: 'Admin',
subtitle: 'Manage site information and header navigation links',
subtitle: 'Manage site information and navigation links',
siteInfo: 'Site information',
siteName: 'Site name',
siteNamePlaceholder: 'Shown in the browser title and page header',
@@ -150,13 +142,24 @@ const enUS: MessageSchema = {
logoClear: 'Clear logo',
logoUploadSuccess: 'Logo updated',
logoClearSuccess: 'Logo cleared',
favicon: 'Favicon URL',
faviconPlaceholder: 'https://example.com/favicon.ico',
faviconHint: 'Supports ICO, PNG, SVG and other image formats, 32×32 or 64×64 recommended; falls back to the default icon when empty',
faviconPreview: 'Preview',
faviconUpload: 'Upload favicon',
faviconClear: 'Clear favicon',
faviconUploadSuccess: 'Favicon updated',
faviconClearSuccess: 'Favicon cleared',
footer: 'Footer copyright text',
footerPlaceholder: 'Falls back to the localized default when empty',
save: 'Save changes',
saveSuccess: 'Site information updated',
nav: {
title: 'Header links',
subtitle: 'Configure the links shown in the site header and their localized labels',
title: 'Navigation links',
headerTitle: 'Header links',
headerSubtitle: 'Configure the links shown in the site header and their localized labels',
footerTitle: 'Footer links',
footerSubtitle: 'Configure the links shown in the site footer and their localized labels',
add: 'Add link',
edit: 'Edit link',
delete: 'Delete',
@@ -195,6 +198,11 @@ const enUS: MessageSchema = {
logoSize: 'Image size must not exceed {size} MB',
logoInvalid: 'The image is not valid, please choose another one',
logoUpload: 'Failed to upload the logo, please try again later',
faviconLength: 'Favicon URL must be at most 500 characters',
faviconType: 'Please choose an ICO, PNG, or SVG image file',
faviconSize: 'Image size must not exceed {size} MB',
faviconInvalid: 'The image is not valid, please choose another one',
faviconUpload: 'Failed to upload the favicon, please try again later',
footerLength: 'Footer text must be at most 1000 characters',
invalid: 'The submitted information is invalid, please check and retry',
network: 'Network error, please try again later',
+19 -11
View File
@@ -39,14 +39,6 @@ const jaJP: MessageSchema = {
video: '動画',
},
footer: {
links: {
about: '運営会社',
help: 'ヘルプセンター',
terms: '利用規約',
privacy: 'プライバシーポリシー',
guidelines: 'コミュニティガイドライン',
contact: 'お問い合わせ',
},
copyright: 'Rill 動画配信サービス · ページフレームのプレースホルダー · 画像はすべて空のプレースホルダーです',
},
language: {
@@ -138,7 +130,7 @@ const jaJP: MessageSchema = {
},
admin: {
title: '管理画面',
subtitle: 'サイト情報とヘッダーナビゲーションを管理します',
subtitle: 'サイト情報とナビゲーションリンクを管理します',
siteInfo: 'サイト情報',
siteName: 'サイト名',
siteNamePlaceholder: 'ブラウザのタイトルとページヘッダーに表示されます',
@@ -150,13 +142,24 @@ const jaJP: MessageSchema = {
logoClear: 'ロゴをクリア',
logoUploadSuccess: 'ロゴを更新しました',
logoClearSuccess: 'ロゴをクリアしました',
favicon: 'Favicon URL',
faviconPlaceholder: 'https://example.com/favicon.ico',
faviconHint: 'ICO・PNG・SVG などの画像に対応(32×32 または 64×64 を推奨)。空欄の場合は既定のアイコンを使用します',
faviconPreview: 'プレビュー',
faviconUpload: 'Favicon をアップロード',
faviconClear: 'Favicon をクリア',
faviconUploadSuccess: 'Favicon を更新しました',
faviconClearSuccess: 'Favicon をクリアしました',
footer: 'フッターの著作権表示',
footerPlaceholder: '空欄の場合は多言語の既定文案を使用します',
save: '変更を保存',
saveSuccess: 'サイト情報を更新しました',
nav: {
title: 'ヘッダーリンク',
subtitle: 'ヘッダーに表示するリンクと多言語の文言を設定します',
title: 'ナビゲーションリンク',
headerTitle: 'ヘッダーリンク',
headerSubtitle: 'ヘッダーに表示するリンクと多言語の文言を設定します',
footerTitle: 'フッターリンク',
footerSubtitle: 'フッターに表示するリンクと多言語の文言を設定します',
add: 'リンクを追加',
edit: 'リンクを編集',
delete: '削除',
@@ -195,6 +198,11 @@ const jaJP: MessageSchema = {
logoSize: '画像サイズは {size} MB 以内にしてください',
logoInvalid: '画像が要件を満たしていません。別の画像を選択してください',
logoUpload: 'ロゴのアップロードに失敗しました。後でもう一度お試しください',
faviconLength: 'Favicon URL は 500 文字以内で入力してください',
faviconType: 'ICO・PNG・SVG などの画像ファイルを選択してください',
faviconSize: '画像サイズは {size} MB 以内にしてください',
faviconInvalid: '画像が要件を満たしていません。別の画像を選択してください',
faviconUpload: 'Favicon のアップロードに失敗しました。後でもう一度お試しください',
footerLength: 'フッター文言は 1000 文字以内で入力してください',
invalid: '入力内容が無効です。確認してもう一度お試しください',
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
+19 -11
View File
@@ -37,14 +37,6 @@ const zhCN = {
video: '视频',
},
footer: {
links: {
about: '关于我们',
help: '帮助中心',
terms: '用户协议',
privacy: '隐私政策',
guidelines: '社区规范',
contact: '联系我们',
},
copyright: 'Rill 流媒体服务平台 · 页面框架占位 · 所有图片均为空白占位框',
},
language: {
@@ -136,7 +128,7 @@ const zhCN = {
},
admin: {
title: '后台管理',
subtitle: '维护站点信息与头部导航链接',
subtitle: '维护站点信息与导航链接',
siteInfo: '站点信息',
siteName: '网站名称',
siteNamePlaceholder: '显示在浏览器标题与页面头部',
@@ -148,13 +140,24 @@ const zhCN = {
logoClear: '清空 Logo',
logoUploadSuccess: 'Logo 已更新',
logoClearSuccess: 'Logo 已清除',
favicon: 'Favicon 图标地址',
faviconPlaceholder: 'https://example.com/favicon.ico',
faviconHint: '支持 ICO、PNG、SVG 等图片,建议 32×32 或 64×64;留空时使用默认图标',
faviconPreview: '预览',
faviconUpload: '上传 Favicon',
faviconClear: '清空 Favicon',
faviconUploadSuccess: 'Favicon 已更新',
faviconClearSuccess: 'Favicon 已清除',
footer: '页脚版权文案',
footerPlaceholder: '留空时使用多语言默认文案',
save: '保存修改',
saveSuccess: '站点信息已更新',
nav: {
title: '头部导航链接',
subtitle: '配置显示在页面头部的链接与多语言文案',
title: '导航链接',
headerTitle: '头部导航链接',
headerSubtitle: '配置显示在页面头部的链接与多语言文案',
footerTitle: '底部导航链接',
footerSubtitle: '配置显示在页面底部的链接与多语言文案',
add: '新增链接',
edit: '编辑链接',
delete: '删除',
@@ -193,6 +196,11 @@ const zhCN = {
logoSize: '图片大小不能超过 {size} MB',
logoInvalid: '图片不符合要求,请重新选择',
logoUpload: 'Logo 上传失败,请稍后重试',
faviconLength: 'Favicon 地址最多 500 个字符',
faviconType: '请选择 ICO、PNG、SVG 等图片文件',
faviconSize: '图片大小不能超过 {size} MB',
faviconInvalid: '图片不符合要求,请重新选择',
faviconUpload: 'Favicon 上传失败,请稍后重试',
footerLength: '页脚文案最多 1000 个字符',
invalid: '提交的信息无效,请检查后重试',
network: '网络异常,请稍后重试',
+25 -2
View File
@@ -2,10 +2,33 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { getNavLinks, type NavLink } from '@/api/nav'
export const NAV_POSITION_HEADER = 'header'
export const NAV_POSITION_FOOTER = 'footer'
// navLabel 按当前语言取链接文案,缺失时回退中文与首个非空文案。
export function navLabel(link: NavLink, locale: string): string {
const current = link.translations.find((item) => item.locale === locale)?.label
if (current) {
return current
}
const fallback = link.translations.find((item) => item.locale === 'zh-CN')?.label
return fallback ?? link.translations[0]?.label ?? ''
}
// isInternalNavUrl 是否站内路径(使用前端路由跳转)。
export function isInternalNavUrl(url: string): boolean {
return url.startsWith('/')
}
export const useNavStore = defineStore('nav', () => {
const links = ref<NavLink[]>([])
const hasLinks = computed(() => links.value.length > 0)
const headerLinks = computed(() =>
links.value.filter((link) => link.position !== NAV_POSITION_FOOTER),
)
const footerLinks = computed(() =>
links.value.filter((link) => link.position === NAV_POSITION_FOOTER),
)
async function load() {
try {
@@ -15,5 +38,5 @@ export const useNavStore = defineStore('nav', () => {
}
}
return { links, hasLinks, load }
return { links, headerLinks, footerLinks, load }
})
+33 -2
View File
@@ -1,9 +1,11 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import {
deleteSiteFavicon as deleteSiteFaviconRequest,
deleteSiteLogo as deleteSiteLogoRequest,
getSiteInfo,
updateSiteInfo,
uploadSiteFavicon as uploadSiteFaviconRequest,
uploadSiteLogo as uploadSiteLogoRequest,
type SiteInfo,
type UpdateSitePayload,
@@ -14,14 +16,17 @@ const DEFAULT_SITE_NAME = 'Rill'
export const useSiteStore = defineStore('site', () => {
const siteName = ref('')
const logo = ref('')
const favicon = ref('')
const footer = ref('')
const name = computed(() => siteName.value || DEFAULT_SITE_NAME)
const hasLogo = computed(() => logo.value !== '')
const hasFavicon = computed(() => favicon.value !== '')
function apply(info: SiteInfo) {
siteName.value = info.site_name.trim()
logo.value = info.logo.trim()
favicon.value = info.favicon.trim()
footer.value = info.footer.trim()
}
@@ -39,7 +44,7 @@ export const useSiteStore = defineStore('site', () => {
return info
}
async function uploadLogo(file: Blob): Promise<SiteInfo> {
async function uploadLogo(file: File): Promise<SiteInfo> {
const info = await uploadSiteLogoRequest(file)
apply(info)
return info
@@ -51,5 +56,31 @@ export const useSiteStore = defineStore('site', () => {
return info
}
return { siteName, logo, footer, name, hasLogo, load, save, uploadLogo, removeLogo }
async function uploadFavicon(file: File): Promise<SiteInfo> {
const info = await uploadSiteFaviconRequest(file)
apply(info)
return info
}
async function removeFavicon(): Promise<SiteInfo> {
const info = await deleteSiteFaviconRequest()
apply(info)
return info
}
return {
siteName,
logo,
favicon,
footer,
name,
hasLogo,
hasFavicon,
load,
save,
uploadLogo,
removeLogo,
uploadFavicon,
removeFavicon,
}
})
+4 -1
View File
@@ -3,5 +3,8 @@ import NavLinksCard from '@/components/admin/NavLinksCard.vue'
</script>
<template>
<NavLinksCard />
<div class="space-y-5">
<NavLinksCard position="header" />
<NavLinksCard position="footer" />
</div>
</template>
+2 -25
View File
@@ -1,13 +1,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import VideoSection from '@/components/home/VideoSection.vue'
import LanguageSwitcher from '@/components/layout/LanguageSwitcher.vue'
import ThemeSwitcher from '@/components/layout/ThemeSwitcher.vue'
import AppFooter from '@/components/layout/AppFooter.vue'
import { hotVideos, recommendVideos } from '@/mock/home'
import { useSiteStore } from '@/stores/site'
const { t } = useI18n()
const site = useSiteStore()
const recommendTabs = [
'category.all',
@@ -15,15 +12,6 @@ const recommendTabs = [
'category.movie',
'category.video',
] as const
const footerLinkKeys = [
'footer.links.about',
'footer.links.help',
'footer.links.terms',
'footer.links.privacy',
'footer.links.guidelines',
'footer.links.contact',
] as const
</script>
<template>
@@ -35,17 +23,6 @@ const footerLinkKeys = [
/>
<VideoSection :title="t('home.hot')" :videos="hotVideos" />
<footer class="rounded-xl bg-surface px-6 py-6 text-center text-xs text-content-3">
<div class="mb-4 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<ThemeSwitcher />
<LanguageSwitcher />
</div>
<nav class="mb-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
<a v-for="key in footerLinkKeys" :key="key" href="#" class="hover:text-primary">
{{ t(key) }}
</a>
</nav>
<p>{{ site.footer || t('footer.copyright') }}</p>
</footer>
<AppFooter />
</div>
</template>
+2 -28
View File
@@ -1,37 +1,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import LanguageSwitcher from '@/components/layout/LanguageSwitcher.vue'
import ThemeSwitcher from '@/components/layout/ThemeSwitcher.vue'
import { useSiteStore } from '@/stores/site'
const { t } = useI18n()
const site = useSiteStore()
const footerLinkKeys = [
'footer.links.about',
'footer.links.help',
'footer.links.terms',
'footer.links.privacy',
'footer.links.guidelines',
'footer.links.contact',
] as const
import AppFooter from '@/components/layout/AppFooter.vue'
</script>
<template>
<div
class="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-[1600px] flex-col px-4 py-4"
>
<footer class="mt-auto rounded-xl bg-surface px-6 py-6 text-center text-xs text-content-3">
<div class="mb-4 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
<ThemeSwitcher />
<LanguageSwitcher />
</div>
<nav class="mb-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
<a v-for="key in footerLinkKeys" :key="key" href="#" class="hover:text-primary">
{{ t(key) }}
</a>
</nav>
<p>{{ site.footer || t('footer.copyright') }}</p>
</footer>
<AppFooter class="mt-auto" />
</div>
</template>
+2
View File
@@ -70,6 +70,8 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
admin.PUT("/site", site.Update(db, cfg))
admin.PUT("/site/logo", site.UploadLogo(db, cfg))
admin.DELETE("/site/logo", site.DeleteLogo(db, cfg))
admin.PUT("/site/favicon", site.UploadFavicon(db, cfg))
admin.DELETE("/site/favicon", site.DeleteFavicon(db, cfg))
admin.GET("/nav-links/list", nav.ListAll(db))
admin.POST("/nav-links", nav.Create(db))
+6
View File
@@ -86,6 +86,9 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
if !db.Migrator().HasTable(&model.NavLinkTranslation{}) {
t.Error("nav_link_translations 表未创建")
}
if !db.Migrator().HasColumn(&model.NavLink{}, "position") {
t.Error("nav_links 表缺少 position 列")
}
if !db.Migrator().HasTable(&schemaMigration{}) {
t.Error("schema_migrations 表未创建")
}
@@ -139,6 +142,9 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
if setting.SiteName != "Rill" || setting.Logo != "" || setting.Footer != "" {
t.Errorf("默认站点信息异常: %+v", setting)
}
if !db.Migrator().HasColumn(&model.SiteSetting{}, "favicon") {
t.Error("site_settings 表缺少 favicon 列")
}
passwordFile := adminPasswordPath(db)
raw, err := os.ReadFile(passwordFile)
+14
View File
@@ -125,6 +125,20 @@ var migrations = []Migration{
return tx.AutoMigrate(&model.NavLink{}, &model.NavLinkTranslation{})
},
},
{
Version: 11,
Name: "add_nav_link_position",
Up: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.NavLink{})
},
},
{
Version: 12,
Name: "add_site_favicon",
Up: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.SiteSetting{})
},
},
}
// schemaMigration 记录已应用的迁移版本。
+44 -7
View File
@@ -210,10 +210,13 @@ func ParseLocalURL(prefix, value string) (uint, bool) {
return uint(id), true
}
// CanInline 是否可内联展示(避免同源 HTML/SVG 造成存储型 XSS
// cspHeader 用户上传内容统一附加的 CSP:内联展示 SVG 时禁用脚本与外部资源
const cspHeader = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
// CanInline 是否可内联展示。SVG 允许内联,但查看接口会附加 CSP 禁止脚本执行。
func CanInline(mimeType string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) {
case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/avif",
case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/avif", "image/svg+xml",
"video/mp4", "video/webm", "audio/mpeg", "audio/ogg", "audio/wav", "application/pdf", "text/plain":
return true
default:
@@ -221,20 +224,44 @@ func CanInline(mimeType string) bool {
}
}
// IsImageUpload 通过文件头探测上传内容是否为图片,避免仅信任客户端声明的类型。
// IsImageUpload 通过文件头探测上传内容是否为栅格图片,避免仅信任客户端声明的类型。
func IsImageUpload(header *multipart.FileHeader) (bool, error) {
src, err := header.Open()
head, err := readHead(header)
if err != nil {
return false, err
}
return strings.HasPrefix(http.DetectContentType(head), "image/"), nil
}
// IsSVGUpload 通过文件头探测上传内容是否为 SVG(可内联但受 CSP 限制)。
func IsSVGUpload(header *multipart.FileHeader) (bool, error) {
head, err := readHead(header)
if err != nil {
return false, err
}
return isSVGContent(head), nil
}
// readHead 读取上传文件头部,最多 512 字节。
func readHead(header *multipart.FileHeader) ([]byte, error) {
src, err := header.Open()
if err != nil {
return nil, err
}
defer src.Close()
head := make([]byte, 512)
n, err := src.Read(head)
if err != nil && !errors.Is(err, io.EOF) {
return false, err
return nil, err
}
return strings.HasPrefix(http.DetectContentType(head[:n]), "image/"), nil
return head[:n], nil
}
// isSVGContent 判断内容是否为 SVG 文档。
func isSVGContent(head []byte) bool {
lower := strings.ToLower(string(head))
return strings.Contains(lower, "<svg")
}
// detectMimeType 读取文件头部探测 MIME;探测失败时回退扩展名与通用类型。
@@ -250,7 +277,15 @@ func detectMimeType(name string) (string, error) {
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("读取上传内容失败: %w", err)
}
if detected := http.DetectContentType(head[:n]); detected != "" && detected != "application/octet-stream" {
detected := http.DetectContentType(head[:n])
if strings.HasPrefix(detected, "image/") {
return detected, nil
}
// DetectContentType 对 XML 文本返回 text/xml,需单独识别 SVG。
if isSVGContent(head[:n]) {
return "image/svg+xml", nil
}
if detected != "" && detected != "application/octet-stream" {
return detected, nil
}
if byExt := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); byExt != "" {
@@ -274,6 +309,8 @@ func extensionFor(mimeType string) string {
return ".bmp"
case "image/avif":
return ".avif"
case "image/svg+xml":
return ".svg"
case "video/mp4":
return ".mp4"
case "video/webm":
+1
View File
@@ -196,6 +196,7 @@ func View(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
}
c.Header("Content-Disposition", fmt.Sprintf("%s; filename*=UTF-8''%s", disposition, url.PathEscape(record.Name)))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", cspHeader)
c.Header("Cache-Control", viewCacheControl)
c.DataFromReader(http.StatusOK, record.Size, contentType, handle, nil)
}
+8 -1
View File
@@ -8,10 +8,17 @@ const (
NavLinkStatusEnabled int8 = 1
)
// NavLink 头部导航链接,文案按语言存放在 NavLinkTranslation
// 导航链接位置
const (
NavLinkPositionHeader = "header"
NavLinkPositionFooter = "footer"
)
// NavLink 站点导航链接(头部或底部),文案按语言存放在 NavLinkTranslation。
type NavLink struct {
ID uint `gorm:"primaryKey" json:"id"`
URL string `gorm:"size:512;not null" json:"url"`
Position string `gorm:"size:20;not null;default:'header'" json:"position"`
OpenInNewWindow bool `gorm:"not null;default:false" json:"open_in_new_window"`
Sort int `gorm:"not null;default:0" json:"sort"`
Status int8 `gorm:"not null" json:"status"`
+1
View File
@@ -10,6 +10,7 @@ type SiteSetting struct {
ID uint `gorm:"primaryKey" json:"-"`
SiteName string `gorm:"size:100;not null;default:''" json:"site_name" example:"Rill"`
Logo string `gorm:"size:500" json:"logo" example:"https://example.com/logo.png"`
Favicon string `gorm:"size:500" json:"favicon" example:"https://example.com/favicon.ico"`
Footer string `gorm:"size:1000" json:"footer" example:"Copyright © Rill"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"updated_at"`
+15 -6
View File
@@ -20,9 +20,10 @@ type TranslationRequest struct {
Label string `json:"label" binding:"required,max=100" example:"首页"`
}
// Request 创建/更新导航链接请求。
// Request 创建/更新导航链接请求position 缺省为 header
type Request struct {
URL string `json:"url" binding:"required,max=512" example:"/profile"`
Position string `json:"position" binding:"omitempty,oneof=header footer" example:"header"`
OpenInNewWindow bool `json:"open_in_new_window" example:"false"`
Sort int `json:"sort" example:"0"`
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
@@ -30,7 +31,7 @@ type Request struct {
}
// @Summary List nav links
// @Description Public header navigation links (status enabled), ordered by sort ASC then id ASC, with translations.
// @Description Public site navigation links (header and footer, status enabled), ordered by sort ASC then id ASC, with translations and position.
// @Tags public
// @Produce json
// @Success 200 {array} model.NavLink
@@ -48,7 +49,7 @@ func List(db *gorm.DB) gin.HandlerFunc {
}
// @Summary List all nav links
// @Description Admin only. List all header navigation links including disabled ones, with translations.
// @Description Admin only. List all site navigation links including disabled ones, with translations.
// @Tags admin
// @Produce json
// @Success 200 {array} model.NavLink
@@ -69,7 +70,7 @@ func ListAll(db *gorm.DB) gin.HandlerFunc {
}
// @Summary Create a nav link
// @Description Admin only. Create a header navigation link. url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.
// @Description Admin only. Create a site navigation link (position: header or footer, defaults to header). url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.
// @Tags admin
// @Accept json
// @Produce json
@@ -92,9 +93,14 @@ func Create(db *gorm.DB) gin.HandlerFunc {
if req.Status != nil {
status = *req.Status
}
position := strings.TrimSpace(req.Position)
if position == "" {
position = model.NavLinkPositionHeader
}
link := model.NavLink{
URL: strings.TrimSpace(req.URL),
Position: position,
OpenInNewWindow: req.OpenInNewWindow,
Sort: req.Sort,
Status: status,
@@ -121,7 +127,7 @@ func Create(db *gorm.DB) gin.HandlerFunc {
}
// @Summary Update a nav link
// @Description Admin only. Update a header navigation link; translations are replaced by the provided list.
// @Description Admin only. Update a site navigation link; position is kept when omitted; translations are replaced by the provided list.
// @Tags admin
// @Accept json
// @Produce json
@@ -154,6 +160,9 @@ func Update(db *gorm.DB) gin.HandlerFunc {
}
link.URL = strings.TrimSpace(req.URL)
if position := strings.TrimSpace(req.Position); position != "" {
link.Position = position
}
link.OpenInNewWindow = req.OpenInNewWindow
link.Sort = req.Sort
if req.Status != nil {
@@ -181,7 +190,7 @@ func Update(db *gorm.DB) gin.HandlerFunc {
}
// @Summary Delete a nav link
// @Description Admin only. Delete a header navigation link and its translations.
// @Description Admin only. Delete a site navigation link and its translations.
// @Tags admin
// @Produce json
// @Param id path int true "Nav link ID" example(1)
+74
View File
@@ -201,3 +201,77 @@ func TestNavLinkValidation(t *testing.T) {
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
}
}
func TestNavLinkPosition(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
public := env.Router("")
// 缺省为 header。
header := createLink(t, admin, map[string]any{
"url": "/header", "translations": []map[string]string{{"locale": "zh-CN", "label": "头部"}},
})
if header.Position != model.NavLinkPositionHeader {
t.Errorf("缺省位置 = %q, 期望 %q", header.Position, model.NavLinkPositionHeader)
}
footer := createLink(t, admin, map[string]any{
"url": "/footer", "position": "footer", "sort": 10,
"translations": []map[string]string{{"locale": "zh-CN", "label": "底部"}},
})
if footer.Position != model.NavLinkPositionFooter {
t.Errorf("底部链接位置 = %q, 期望 %q", footer.Position, model.NavLinkPositionFooter)
}
w := testutil.Call(t, public, http.MethodGet, "/api/nav-links", nil)
links := decodeLinks(t, w.Body.Bytes())
if len(links) != 2 {
t.Fatalf("公开链接数 = %d, 期望 2", len(links))
}
positions := map[string]string{}
for _, link := range links {
positions[link.URL] = link.Position
}
if positions["/header"] != model.NavLinkPositionHeader || positions["/footer"] != model.NavLinkPositionFooter {
t.Errorf("公开链接位置异常: %+v", positions)
}
// 更新时不传 position 保持原位置,传了则迁移。
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/nav-links/%d", footer.ID), map[string]any{
"url": "/footer", "translations": []map[string]string{{"locale": "zh-CN", "label": "底部"}},
})
if w.Code != http.StatusOK {
t.Fatalf("更新状态码 = %d, body=%s", w.Code, w.Body.String())
}
if kept := decodeLink(t, w.Body.Bytes()); kept.Position != model.NavLinkPositionFooter {
t.Errorf("未传 position 时位置被修改: %q", kept.Position)
}
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/nav-links/%d", footer.ID), map[string]any{
"url": "/footer", "position": "header",
"translations": []map[string]string{{"locale": "zh-CN", "label": "底部"}},
})
if w.Code != http.StatusOK {
t.Fatalf("迁移位置状态码 = %d, body=%s", w.Code, w.Body.String())
}
if moved := decodeLink(t, w.Body.Bytes()); moved.Position != model.NavLinkPositionHeader {
t.Errorf("位置迁移失败: %q", moved.Position)
}
// 非法位置。
if w := testutil.Call(t, admin, http.MethodPost, "/api/nav-links", map[string]any{
"url": "/x", "position": "sidebar",
"translations": []map[string]string{{"locale": "zh-CN", "label": "侧边"}},
}); w.Code != http.StatusBadRequest {
t.Errorf("非法位置状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
}
func decodeLink(t *testing.T, body []byte) model.NavLink {
t.Helper()
var link model.NavLink
if err := json.Unmarshal(body, &link); err != nil {
t.Fatalf("解析链接响应失败: %v, body=%s", err, body)
}
return link
}
+183 -104
View File
@@ -1,7 +1,8 @@
// Package site 提供站点信息(站名、Logo、页脚文案)的读取与管理员更新接口。
// Package site 提供站点信息(站名、Logo、Favicon、页脚文案)的读取与管理员更新接口。
package site
import (
"context"
"errors"
"net/http"
"strings"
@@ -16,15 +17,16 @@ import (
"rill/internal/model"
)
// UpdateRequest 更新站点信息请求,空 logo/footer 表示回退前端默认展示。
// UpdateRequest 更新站点信息请求,空 logo/favicon/footer 表示回退前端默认展示。
type UpdateRequest struct {
SiteName string `json:"site_name" binding:"required,max=100" example:"Rill"`
Logo string `json:"logo" binding:"omitempty,max=500" example:"https://example.com/logo.png"`
Favicon string `json:"favicon" binding:"omitempty,max=500" example:"https://example.com/favicon.ico"`
Footer string `json:"footer" binding:"omitempty,max=1000" example:"Copyright © Rill"`
}
// @Summary Get site settings
// @Description Public site settings: site name, logo URL, and footer text. Returns built-in defaults when the settings row is missing.
// @Description Public site settings: site name, logo URL, favicon URL, and footer text. Returns built-in defaults when the settings row is missing.
// @Tags public
// @Produce json
// @Success 200 {object} model.SiteSetting
@@ -33,12 +35,8 @@ type UpdateRequest struct {
func Get(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, defaultSetting())
return
}
setting, err := loadSetting(ctx, db)
if err != nil {
httpx.RespondDBError(c, err)
return
}
@@ -47,13 +45,13 @@ func Get(db *gorm.DB) gin.HandlerFunc {
}
// @Summary Update site settings
// @Description Admin only. Update the site name, logo URL, and footer text; logo files hosted on this site have their reference count managed automatically. Returns the updated settings.
// @Description Admin only. Update the site name, logo URL, favicon URL, and footer text; logo and favicon files hosted on this site have their reference count managed automatically. Returns the updated settings.
// @Tags admin
// @Accept json
// @Produce json
// @Param site body site.UpdateRequest true "Site settings"
// @Success 200 {object} model.SiteSetting
// @Failure 400 {object} httpx.ErrorResponse "invalid request or logo file not found"
// @Failure 400 {object} httpx.ErrorResponse "invalid request or file not found"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
@@ -73,37 +71,21 @@ func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
}
ctx := c.Request.Context()
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
httpx.RespondDBError(c, err)
return
}
setting = defaultSetting()
setting, err := loadSetting(ctx, db)
if err != nil {
httpx.RespondDBError(c, err)
return
}
oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, setting.Logo)
newID, hasNew := file.ParseLocalURL(cfg.API.Prefix, req.Logo)
oldLogo := setting.Logo
oldFavicon := setting.Favicon
setting.SiteName = req.SiteName
setting.Logo = req.Logo
setting.Logo = strings.TrimSpace(req.Logo)
setting.Favicon = strings.TrimSpace(req.Favicon)
setting.Footer = req.Footer
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if hasNew && (!hasOld || newID != oldID) {
if err := file.Acquire(ctx, tx, newID); err != nil {
return err
}
}
if hasOld && (!hasNew || oldID != newID) {
if err := file.Release(ctx, tx, oldID); err != nil {
return err
}
}
return tx.Save(&setting).Error
})
if err != nil {
if err := saveSetting(ctx, db, cfg, &setting, oldLogo, oldFavicon); err != nil {
if errors.Is(err, file.ErrFileNotFound) {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: logo file not found"})
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: file not found"})
return
}
httpx.RespondDBError(c, err)
@@ -114,7 +96,7 @@ func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
}
// @Summary Upload site logo
// @Description Admin only. Upload an image as the site logo (multipart field file, image only); the logo takes effect immediately and file references are managed automatically.
// @Description Admin only. Upload a raster image as the site logo (multipart field file); the logo takes effect immediately and file references are managed automatically.
// @Tags admin
// @Accept mpfd
// @Produce json
@@ -129,61 +111,20 @@ func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
// @Router /site/logo [put]
func UploadLogo(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
current, ok := auth.CurrentUser(c)
if !ok {
httpx.RespondUnauthorized(c)
return
}
header, ok := file.ReadUpload(c, cfg)
logoURL, ok := uploadAsset(c, db, cfg, false, "logo must be an image")
if !ok {
return
}
if isImage, err := file.IsImageUpload(header); err != nil {
httpx.RespondServerError(c, err, "读取上传图片失败")
return
} else if !isImage {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "logo must be an image"})
return
}
src, err := header.Open()
if err != nil {
httpx.RespondServerError(c, err, "打开上传图片失败")
return
}
defer src.Close()
ctx := c.Request.Context()
saved, err := file.Save(ctx, db, cfg, file.OperatorOf(c, current), header.Filename, src)
setting, err := loadSetting(ctx, db)
if err != nil {
file.RespondSaveError(c, err)
httpx.RespondDBError(c, err)
return
}
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
httpx.RespondDBError(c, err)
return
}
setting = defaultSetting()
}
oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, setting.Logo)
setting.Logo = file.URL(cfg.API.Prefix, saved.ID)
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if !hasOld || oldID != saved.ID {
if err := file.Acquire(ctx, tx, saved.ID); err != nil {
return err
}
}
if hasOld && oldID != saved.ID {
if err := file.Release(ctx, tx, oldID); err != nil {
return err
}
}
return tx.Save(&setting).Error
})
if err != nil {
oldLogo := setting.Logo
setting.Logo = logoURL
if err := saveSetting(ctx, db, cfg, &setting, oldLogo, setting.Favicon); err != nil {
httpx.RespondDBError(c, err)
return
}
@@ -204,33 +145,171 @@ func UploadLogo(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
func DeleteLogo(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
httpx.RespondDBError(c, err)
return
}
setting = defaultSetting()
}
oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, setting.Logo)
setting.Logo = ""
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if hasOld {
if err := file.Release(ctx, tx, oldID); err != nil {
return err
}
}
return tx.Save(&setting).Error
})
setting, err := loadSetting(ctx, db)
if err != nil {
httpx.RespondDBError(c, err)
return
}
oldLogo := setting.Logo
setting.Logo = ""
if err := saveSetting(ctx, db, cfg, &setting, oldLogo, setting.Favicon); err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, setting)
}
}
// @Summary Upload site favicon
// @Description Admin only. Upload an image (raster or SVG) as the site favicon (multipart field file); takes effect immediately and file references are managed automatically. SVG is served inline with a restrictive Content-Security-Policy.
// @Tags admin
// @Accept mpfd
// @Produce json
// @Param file formData file true "Favicon image"
// @Success 200 {object} model.SiteSetting
// @Failure 400 {object} httpx.ErrorResponse "invalid request, empty file, or not an image"
// @Failure 413 {object} httpx.ErrorResponse "file too large"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /site/favicon [put]
func UploadFavicon(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
faviconURL, ok := uploadAsset(c, db, cfg, true, "favicon must be an image or SVG")
if !ok {
return
}
ctx := c.Request.Context()
setting, err := loadSetting(ctx, db)
if err != nil {
httpx.RespondDBError(c, err)
return
}
oldFavicon := setting.Favicon
setting.Favicon = faviconURL
if err := saveSetting(ctx, db, cfg, &setting, setting.Logo, oldFavicon); err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, setting)
}
}
// @Summary Delete site favicon
// @Description Admin only. Clear the site favicon and release the reference of the locally stored favicon file.
// @Tags admin
// @Produce json
// @Success 200 {object} model.SiteSetting
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /site/favicon [delete]
func DeleteFavicon(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
setting, err := loadSetting(ctx, db)
if err != nil {
httpx.RespondDBError(c, err)
return
}
oldFavicon := setting.Favicon
setting.Favicon = ""
if err := saveSetting(ctx, db, cfg, &setting, setting.Logo, oldFavicon); err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, setting)
}
}
// uploadAsset 校验并保存上传的站点图片,返回文件的公开地址。
func uploadAsset(c *gin.Context, db *gorm.DB, cfg *config.Config, allowSVG bool, invalidMessage string) (string, bool) {
current, ok := auth.CurrentUser(c)
if !ok {
httpx.RespondUnauthorized(c)
return "", false
}
header, ok := file.ReadUpload(c, cfg)
if !ok {
return "", false
}
isImage, err := file.IsImageUpload(header)
if err != nil {
httpx.RespondServerError(c, err, "读取上传图片失败")
return "", false
}
if !isImage && allowSVG {
isSVG, err := file.IsSVGUpload(header)
if err != nil {
httpx.RespondServerError(c, err, "读取上传图片失败")
return "", false
}
isImage = isSVG
}
if !isImage {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: invalidMessage})
return "", false
}
src, err := header.Open()
if err != nil {
httpx.RespondServerError(c, err, "打开上传图片失败")
return "", false
}
defer src.Close()
saved, err := file.Save(c.Request.Context(), db, cfg, file.OperatorOf(c, current), header.Filename, src)
if err != nil {
file.RespondSaveError(c, err)
return "", false
}
return file.URL(cfg.API.Prefix, saved.ID), true
}
// syncFileRef 在事务内维护本站文件引用:新值占用、旧值释放,相同地址不重复计数。
func syncFileRef(ctx context.Context, tx *gorm.DB, prefix, oldValue, newValue string) error {
oldID, hasOld := file.ParseLocalURL(prefix, oldValue)
newID, hasNew := file.ParseLocalURL(prefix, newValue)
if hasNew && (!hasOld || newID != oldID) {
if err := file.Acquire(ctx, tx, newID); err != nil {
return err
}
}
if hasOld && (!hasNew || oldID != newID) {
return file.Release(ctx, tx, oldID)
}
return nil
}
// loadSetting 读取站点设置,行缺失时返回默认值。
func loadSetting(ctx context.Context, db *gorm.DB) (model.SiteSetting, error) {
var setting model.SiteSetting
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return defaultSetting(), nil
}
return model.SiteSetting{}, err
}
return setting, nil
}
// saveSetting 在事务中保存设置并同步 logo/favicon 的文件引用。
func saveSetting(ctx context.Context, db *gorm.DB, cfg *config.Config, setting *model.SiteSetting, oldLogo, oldFavicon string) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := syncFileRef(ctx, tx, cfg.API.Prefix, oldLogo, setting.Logo); err != nil {
return err
}
if err := syncFileRef(ctx, tx, cfg.API.Prefix, oldFavicon, setting.Favicon); err != nil {
return err
}
return tx.Save(setting).Error
})
}
// defaultSetting 设置行缺失时的内置默认值。
func defaultSetting() model.SiteSetting {
return model.SiteSetting{ID: model.SiteSettingID, SiteName: "Rill"}
+156
View File
@@ -269,3 +269,159 @@ func TestSiteLogoSizeLimit(t *testing.T) {
t.Errorf("超限 Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusRequestEntityTooLarge, w.Body.String())
}
}
func svgBytes() []byte {
return []byte(`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<rect width="16" height="16" fill="#059669"/>
</svg>`)
}
func TestSiteFaviconUpload(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
public := env.Router("")
if w := testutil.CallMultipart(t, public, http.MethodPut, "/api/site/favicon", "file", "favicon.png", testutil.PNG(t, 8, 8)); w.Code != http.StatusUnauthorized {
t.Errorf("匿名上传 Favicon 状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
normal := env.Router(env.Sign(registerUser(t, env).ID))
if w := testutil.CallMultipart(t, normal, http.MethodPut, "/api/site/favicon", "file", "favicon.png", testutil.PNG(t, 8, 8)); w.Code != http.StatusForbidden {
t.Errorf("普通用户上传 Favicon 状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
}
if w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/favicon", "file", "favicon.txt", []byte("not an image")); w.Code != http.StatusBadRequest {
t.Errorf("非图片 Favicon 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/favicon", "file", "favicon.png", testutil.PNG(t, 16, 16))
if w.Code != http.StatusOK {
t.Fatalf("上传 Favicon 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
first := decodeSetting(t, w.Body.Bytes())
firstID := logoFileID(t, first.Favicon)
if got := refCount(t, env, firstID); got != 1 {
t.Fatalf("Favicon 引用计数 = %d, 期望 1", got)
}
// 相同图片重复上传不重复计数。
if w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/favicon", "file", "same.png", testutil.PNG(t, 16, 16)); w.Code != http.StatusOK {
t.Fatalf("重复上传状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, firstID); got != 1 {
t.Errorf("重复上传后引用计数 = %d, 期望 1", got)
}
// 换图释放旧引用。
w = testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/favicon", "file", "new.png", testutil.PNG(t, 24, 24))
if w.Code != http.StatusOK {
t.Fatalf("更换 Favicon 状态码 = %d, body=%s", w.Code, w.Body.String())
}
second := decodeSetting(t, w.Body.Bytes())
secondID := logoFileID(t, second.Favicon)
if secondID == firstID {
t.Fatal("更换 Favicon 应生成新文件")
}
if got := refCount(t, env, firstID); got != 0 {
t.Errorf("旧 Favicon 引用计数 = %d, 期望 0", got)
}
if got := refCount(t, env, secondID); got != 1 {
t.Errorf("新 Favicon 引用计数 = %d, 期望 1", got)
}
// 清空并释放引用。
w = testutil.Call(t, admin, http.MethodDelete, "/api/site/favicon", nil)
if w.Code != http.StatusOK {
t.Fatalf("清空 Favicon 状态码 = %d, body=%s", w.Code, w.Body.String())
}
if cleared := decodeSetting(t, w.Body.Bytes()); cleared.Favicon != "" {
t.Errorf("清空后 Favicon = %q, 期望空", cleared.Favicon)
}
if got := refCount(t, env, secondID); got != 0 {
t.Errorf("清空后引用计数 = %d, 期望 0", got)
}
}
func TestSiteFaviconSVG(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
public := env.Router("")
// Logo 保持仅栅格图片。
if w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "file", "logo.svg", svgBytes()); w.Code != http.StatusBadRequest {
t.Errorf("SVG Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/favicon", "file", "favicon.svg", svgBytes())
if w.Code != http.StatusOK {
t.Fatalf("上传 SVG Favicon 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
setting := decodeSetting(t, w.Body.Bytes())
faviconID := logoFileID(t, setting.Favicon)
var record model.File
if err := env.DB.First(&record, faviconID).Error; err != nil {
t.Fatalf("查询文件记录失败: %v", err)
}
if record.MimeType != "image/svg+xml" || record.Extension != ".svg" {
t.Errorf("SVG 文件元数据异常: mime=%q ext=%q", record.MimeType, record.Extension)
}
w = testutil.Call(t, public, http.MethodGet, setting.Favicon, nil)
if w.Code != http.StatusOK {
t.Fatalf("公开查看 SVG 状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "image/svg+xml") {
t.Errorf("Content-Type = %q, 期望 image/svg+xml", got)
}
if got := w.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
t.Errorf("SVG 应内联展示, Content-Disposition = %q", got)
}
if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "default-src 'none'") || !strings.Contains(got, "sandbox") {
t.Errorf("SVG 缺少 CSP 防护: %q", got)
}
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("缺少 nosniff 头: %q", got)
}
}
func TestSiteFaviconUpdateReference(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
w := testutil.CallMultipart(t, admin, http.MethodPost, "/api/files", "file", "favicon.png", testutil.PNG(t, 10, 10))
if w.Code != http.StatusCreated {
t.Fatalf("上传文件状态码 = %d, body=%s", w.Code, w.Body.String())
}
var record model.File
if err := json.Unmarshal(w.Body.Bytes(), &record); err != nil {
t.Fatalf("解析文件响应失败: %v", err)
}
faviconURL := "/api/files/" + strconv.FormatUint(uint64(record.ID), 10)
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "favicon": faviconURL}); w.Code != http.StatusOK {
t.Fatalf("挂载本地 Favicon 状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, record.ID); got != 1 {
t.Errorf("挂载后引用计数 = %d, 期望 1", got)
}
// 同值再次保存不重复计数。
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "favicon": faviconURL}); w.Code != http.StatusOK {
t.Fatalf("重复保存状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, record.ID); got != 1 {
t.Errorf("重复保存后引用计数 = %d, 期望 1", got)
}
// 换成外链释放本地引用。
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "favicon": "https://example.com/favicon.ico"}); w.Code != http.StatusOK {
t.Fatalf("切换外链状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, record.ID); got != 0 {
t.Errorf("切换外链后引用计数 = %d, 期望 0", got)
}
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "favicon": "/api/files/9999"}); w.Code != http.StatusBadRequest {
t.Errorf("无效本地 Favicon 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
}