新增底部导航链接配置与 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:
29 files changed
+1264
-279
No files matched your search
+137
-9
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
|
||||
@@ -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: '网络异常,请稍后重试',
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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,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"`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user