From 588b63a15b4df74148db02deb785b4ab742c70c8 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 21 Sep 2026 23:23:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BA=95=E9=83=A8=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E9=93=BE=E6=8E=A5=E9=85=8D=E7=BD=AE=E4=B8=8E=20Favico?= =?UTF-8?q?n=20=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 文档 --- docs/docs.go | 146 ++++++++- docs/swagger.json | 146 ++++++++- docs/swagger.yaml | 124 ++++++-- frontend/src/App.vue | 14 + frontend/src/api/nav.ts | 2 + frontend/src/api/site.ts | 19 +- .../src/components/admin/NavLinksCard.vue | 38 ++- .../src/components/admin/SiteInfoForm.vue | 144 +++++++++ frontend/src/components/layout/AppFooter.vue | 48 +++ frontend/src/components/layout/AppHeader.vue | 26 +- frontend/src/i18n/locales/en-US.ts | 30 +- frontend/src/i18n/locales/ja-JP.ts | 30 +- frontend/src/i18n/locales/zh-CN.ts | 30 +- frontend/src/stores/nav.ts | 27 +- frontend/src/stores/site.ts | 35 ++- frontend/src/views/AdminNavLinksView.vue | 5 +- frontend/src/views/DemoView.vue | 27 +- frontend/src/views/HomeView.vue | 30 +- internal/api/api.go | 2 + internal/database/database_test.go | 6 + internal/database/migrate.go | 14 + internal/file/file.go | 51 +++- internal/file/handler.go | 1 + internal/model/nav_link.go | 9 +- internal/model/site.go | 1 + internal/nav/nav.go | 21 +- internal/nav/nav_test.go | 74 +++++ internal/site/site.go | 287 +++++++++++------- internal/site/site_test.go | 156 ++++++++++ 29 files changed, 1264 insertions(+), 279 deletions(-) create mode 100644 frontend/src/components/layout/AppFooter.vue diff --git a/docs/docs.go b/docs/docs.go index 2e90a2c..e8e7196 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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, diff --git a/docs/swagger.json b/docs/swagger.json index ba2265c..4573e69 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1f2263c..1cdd60f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index dd28865..d04d481 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,6 +15,20 @@ watch( }, { immediate: true }, ) + +watch( + () => site.favicon, + (favicon) => { + let link = document.querySelector('link[rel="icon"]') + if (!link) { + link = document.createElement('link') + link.rel = 'icon' + document.head.appendChild(link) + } + link.href = favicon || '/favicon.ico' + }, + { immediate: true }, +)