diff --git a/docs/docs.go b/docs/docs.go index d6e0e8b..e725527 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -889,7 +889,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Admin only. Update the site name, logo URL, and footer text; returns the updated 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.", "consumes": [ "application/json" ], @@ -919,7 +919,7 @@ const docTemplate = `{ } }, "400": { - "description": "invalid request", + "description": "invalid request or logo file not found", "schema": { "$ref": "#/definitions/httpx.ErrorResponse" } @@ -945,6 +945,114 @@ const docTemplate = `{ } } }, + "/site/logo": { + "put": { + "security": [ + { + "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.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Upload site logo", + "parameters": [ + { + "type": "file", + "description": "Logo 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 logo and release the reference of the locally stored logo file.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete site logo", + "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" + } + } + } + } + }, "/user-groups": { "get": { "security": [ diff --git a/docs/swagger.json b/docs/swagger.json index a2e8b94..9939e14 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -882,7 +882,7 @@ "BearerAuth": [] } ], - "description": "Admin only. Update the site name, logo URL, and footer text; returns the updated 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.", "consumes": [ "application/json" ], @@ -912,7 +912,7 @@ } }, "400": { - "description": "invalid request", + "description": "invalid request or logo file not found", "schema": { "$ref": "#/definitions/httpx.ErrorResponse" } @@ -938,6 +938,114 @@ } } }, + "/site/logo": { + "put": { + "security": [ + { + "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.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Upload site logo", + "parameters": [ + { + "type": "file", + "description": "Logo 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 logo and release the reference of the locally stored logo file.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete site logo", + "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" + } + } + } + } + }, "/user-groups": { "get": { "security": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d3b5c2d..1c3fe20 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -944,8 +944,9 @@ paths: put: consumes: - application/json - description: Admin only. Update the site name, logo URL, and footer text; returns - the updated 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. parameters: - description: Site settings in: body @@ -961,7 +962,7 @@ paths: schema: $ref: '#/definitions/model.SiteSetting' "400": - description: invalid request + description: invalid request or logo file not found schema: $ref: '#/definitions/httpx.ErrorResponse' "401": @@ -981,6 +982,78 @@ paths: summary: Update site settings tags: - admin + /site/logo: + delete: + description: Admin only. Clear the site logo and release the reference of the + locally stored logo 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 logo + tags: + - admin + 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. + parameters: + - description: Logo 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 logo + tags: + - admin /user-groups: get: description: List user groups ordered by id ASC. page starts at 1; page_size diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index ca3db05..83044ac 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -23,3 +23,16 @@ export function updateSiteInfo(payload: UpdateSitePayload): Promise { body: JSON.stringify(payload), }) } + +export function uploadSiteLogo(file: Blob, filename = 'logo.png'): Promise { + const form = new FormData() + form.append('file', file, filename) + return request('/site/logo', { + method: 'PUT', + body: form, + }) +} + +export function deleteSiteLogo(): Promise { + return request('/site/logo', { method: 'DELETE' }) +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 57f6f28..c605715 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -147,8 +147,12 @@ const enUS: MessageSchema = { siteNamePlaceholder: 'Shown in the browser title and page header', logo: 'Logo image URL', logoPlaceholder: 'https://example.com/logo.png', - logoHint: 'Falls back to the default “R” mark when empty', + logoHint: 'Paste an external URL or upload an image (takes effect immediately); falls back to the default “R” mark when empty', logoPreview: 'Preview', + logoUpload: 'Upload logo', + logoClear: 'Clear logo', + logoUploadSuccess: 'Logo updated', + logoClearSuccess: 'Logo cleared', footer: 'Footer copyright text', footerPlaceholder: 'Falls back to the localized default when empty', save: 'Save changes', @@ -157,6 +161,10 @@ const enUS: MessageSchema = { siteNameRequired: 'Site name is required', siteNameLength: 'Site name must be at most 100 characters', logoLength: 'Logo URL must be at most 500 characters', + logoType: 'Please choose an image file', + 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', 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', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 9ba68e0..b39b55b 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -147,8 +147,12 @@ const jaJP: MessageSchema = { siteNamePlaceholder: 'ブラウザのタイトルとページヘッダーに表示されます', logo: 'ロゴ画像 URL', logoPlaceholder: 'https://example.com/logo.png', - logoHint: '空欄の場合は既定の「R」マークを表示します', + logoHint: '外部 URL を入力するか、画像をアップロードできます(アップロードは即時反映)。空欄の場合は既定の「R」マークを表示します', logoPreview: 'プレビュー', + logoUpload: 'ロゴをアップロード', + logoClear: 'ロゴをクリア', + logoUploadSuccess: 'ロゴを更新しました', + logoClearSuccess: 'ロゴをクリアしました', footer: 'フッターの著作権表示', footerPlaceholder: '空欄の場合は多言語の既定文案を使用します', save: '変更を保存', @@ -157,6 +161,10 @@ const jaJP: MessageSchema = { siteNameRequired: 'サイト名を入力してください', siteNameLength: 'サイト名は 100 文字以内で入力してください', logoLength: 'ロゴ URL は 500 文字以内で入力してください', + logoType: '画像ファイルを選択してください', + logoSize: '画像サイズは {size} MB 以内にしてください', + logoInvalid: '画像が要件を満たしていません。別の画像を選択してください', + logoUpload: 'ロゴのアップロードに失敗しました。後でもう一度お試しください', footerLength: 'フッター文言は 1000 文字以内で入力してください', invalid: '入力内容が無効です。確認してもう一度お試しください', network: 'ネットワークエラーが発生しました。後でもう一度お試しください', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 10604c5..7a5d39a 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -145,8 +145,12 @@ const zhCN = { siteNamePlaceholder: '显示在浏览器标题与页面头部', logo: 'Logo 图片地址', logoPlaceholder: 'https://example.com/logo.png', - logoHint: '留空时显示默认的 “R” 标识', + logoHint: '可直接填写外链地址,或上传图片(上传后立即生效);留空时显示默认的 “R” 标识', logoPreview: '预览', + logoUpload: '上传 Logo', + logoClear: '清空 Logo', + logoUploadSuccess: 'Logo 已更新', + logoClearSuccess: 'Logo 已清除', footer: '页脚版权文案', footerPlaceholder: '留空时使用多语言默认文案', save: '保存修改', @@ -155,6 +159,10 @@ const zhCN = { siteNameRequired: '请填写网站名称', siteNameLength: '网站名称最多 100 个字符', logoLength: 'Logo 地址最多 500 个字符', + logoType: '请选择图片文件', + logoSize: '图片大小不能超过 {size} MB', + logoInvalid: '图片不符合要求,请重新选择', + logoUpload: 'Logo 上传失败,请稍后重试', footerLength: '页脚文案最多 1000 个字符', invalid: '提交的信息无效,请检查后重试', network: '网络异常,请稍后重试', diff --git a/frontend/src/stores/site.ts b/frontend/src/stores/site.ts index 2bba40c..f601f58 100644 --- a/frontend/src/stores/site.ts +++ b/frontend/src/stores/site.ts @@ -1,6 +1,13 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { getSiteInfo, updateSiteInfo, type SiteInfo, type UpdateSitePayload } from '@/api/site' +import { + deleteSiteLogo as deleteSiteLogoRequest, + getSiteInfo, + updateSiteInfo, + uploadSiteLogo as uploadSiteLogoRequest, + type SiteInfo, + type UpdateSitePayload, +} from '@/api/site' const DEFAULT_SITE_NAME = 'Rill' @@ -32,5 +39,17 @@ export const useSiteStore = defineStore('site', () => { return info } - return { siteName, logo, footer, name, hasLogo, load, save } + async function uploadLogo(file: Blob): Promise { + const info = await uploadSiteLogoRequest(file) + apply(info) + return info + } + + async function removeLogo(): Promise { + const info = await deleteSiteLogoRequest() + apply(info) + return info + } + + return { siteName, logo, footer, name, hasLogo, load, save, uploadLogo, removeLogo } }) diff --git a/frontend/src/views/AdminSiteView.vue b/frontend/src/views/AdminSiteView.vue index 122cc16..a32bafd 100644 --- a/frontend/src/views/AdminSiteView.vue +++ b/frontend/src/views/AdminSiteView.vue @@ -45,8 +45,71 @@ const [footer, footerProps] = defineField('footer') const submitError = ref('') const saved = ref(false) +const logoInput = ref(null) +const logoBusy = ref(false) +const logoError = ref('') +const logoNotice = ref('') + +const MAX_LOGO_MB = 10 + const logoPreview = computed(() => logo.value.trim()) +function resetLogoInput() { + if (logoInput.value) { + logoInput.value.value = '' + } +} + +async function onLogoSelected(event: Event) { + logoError.value = '' + logoNotice.value = '' + const file = (event.target as HTMLInputElement).files?.[0] + if (!file) { + return + } + if (!file.type.startsWith('image/')) { + logoError.value = t('admin.errors.logoType') + resetLogoInput() + return + } + if (file.size > MAX_LOGO_MB * 1024 * 1024) { + logoError.value = t('admin.errors.logoSize', { size: MAX_LOGO_MB }) + resetLogoInput() + return + } + + logoBusy.value = true + try { + await site.uploadLogo(file) + logo.value = site.logo + logoNotice.value = t('admin.logoUploadSuccess') + } catch (error) { + if (error instanceof ApiError && (error.status === 400 || error.status === 413)) { + logoError.value = t('admin.errors.logoInvalid') + } else { + logoError.value = t('admin.errors.logoUpload') + } + } finally { + logoBusy.value = false + resetLogoInput() + } +} + +async function onLogoClear() { + logoError.value = '' + logoNotice.value = '' + logoBusy.value = true + try { + await site.removeLogo() + logo.value = site.logo + logoNotice.value = t('admin.logoClearSuccess') + } catch { + logoError.value = t('admin.errors.logoUpload') + } finally { + logoBusy.value = false + } +} + onMounted(async () => { await site.load() setValues({ @@ -142,6 +205,37 @@ const onSubmit = handleSubmit(async (values) => { R + +
+ + + +
+

+ {{ logoError }} +

+

{{ logoNotice }}

{{ t('admin.logoHint') }}

diff --git a/internal/api/api.go b/internal/api/api.go index 28b5970..d8b16f7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -65,7 +65,9 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) { admin := rg.Group("", authn.RequireAuth(db), auth.RequireAdmin()) { - admin.PUT("/site", site.Update(db)) + admin.PUT("/site", site.Update(db, cfg)) + admin.PUT("/site/logo", site.UploadLogo(db, cfg)) + admin.DELETE("/site/logo", site.DeleteLogo(db, cfg)) users := admin.Group("/users") { diff --git a/internal/avatar/avatar.go b/internal/avatar/avatar.go index f1ccf2e..349b6b6 100644 --- a/internal/avatar/avatar.go +++ b/internal/avatar/avatar.go @@ -2,11 +2,7 @@ package avatar import ( - "errors" - "io" - "mime/multipart" "net/http" - "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -43,7 +39,7 @@ func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { if !ok { return } - if isImage, err := isImageUpload(header); err != nil { + if isImage, err := file.IsImageUpload(header); err != nil { httpx.RespondServerError(c, err, "读取上传图片失败") return } else if !isImage { @@ -126,19 +122,3 @@ func Delete(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { c.JSON(http.StatusOK, current) } } - -// isImageUpload 通过文件头探测是否为图片,避免仅信任客户端声明的类型。 -func isImageUpload(header *multipart.FileHeader) (bool, error) { - src, err := header.Open() - if err != nil { - return false, 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 strings.HasPrefix(http.DetectContentType(head[:n]), "image/"), nil -} diff --git a/internal/file/file.go b/internal/file/file.go index 98b0aa3..af23dba 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "mime" + "mime/multipart" "net/http" "os" "path" @@ -220,6 +221,22 @@ func CanInline(mimeType string) bool { } } +// IsImageUpload 通过文件头探测上传内容是否为图片,避免仅信任客户端声明的类型。 +func IsImageUpload(header *multipart.FileHeader) (bool, error) { + src, err := header.Open() + if err != nil { + return false, 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 strings.HasPrefix(http.DetectContentType(head[:n]), "image/"), nil +} + // detectMimeType 读取文件头部探测 MIME;探测失败时回退扩展名与通用类型。 func detectMimeType(name string) (string, error) { handle, err := os.Open(name) diff --git a/internal/site/site.go b/internal/site/site.go index 782dd4f..d3be068 100644 --- a/internal/site/site.go +++ b/internal/site/site.go @@ -9,6 +9,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "rill/internal/auth" + "rill/internal/config" + "rill/internal/file" "rill/internal/httpx" "rill/internal/model" ) @@ -44,19 +47,19 @@ func Get(db *gorm.DB) gin.HandlerFunc { } // @Summary Update site settings -// @Description Admin only. Update the site name, logo URL, and footer text; returns the updated 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. // @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" +// @Failure 400 {object} httpx.ErrorResponse "invalid request or logo 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" -// @Failure 500 {object} httpx.ErrorResponse +// @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 [put] -func Update(db *gorm.DB) gin.HandlerFunc { +func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { var req UpdateRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -79,10 +82,148 @@ func Update(db *gorm.DB) gin.HandlerFunc { setting = defaultSetting() } + oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, setting.Logo) + newID, hasNew := file.ParseLocalURL(cfg.API.Prefix, req.Logo) + setting.SiteName = req.SiteName setting.Logo = req.Logo setting.Footer = req.Footer - if err := db.WithContext(ctx).Save(&setting).Error; err != nil { + 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 errors.Is(err, file.ErrFileNotFound) { + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: logo file not found"}) + return + } + httpx.RespondDBError(c, err) + return + } + c.JSON(http.StatusOK, setting) + } +} + +// @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. +// @Tags admin +// @Accept mpfd +// @Produce json +// @Param file formData file true "Logo 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/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) + 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) + if err != nil { + file.RespondSaveError(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 { + httpx.RespondDBError(c, err) + return + } + c.JSON(http.StatusOK, setting) + } +} + +// @Summary Delete site logo +// @Description Admin only. Clear the site logo and release the reference of the locally stored logo 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/logo [delete] +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 + }) + if err != nil { httpx.RespondDBError(c, err) return } diff --git a/internal/site/site_test.go b/internal/site/site_test.go index 54c157b..cddde46 100644 --- a/internal/site/site_test.go +++ b/internal/site/site_test.go @@ -3,6 +3,7 @@ package site_test import ( "encoding/json" "net/http" + "strconv" "strings" "testing" @@ -114,3 +115,157 @@ func TestSiteSettingsValidation(t *testing.T) { t.Errorf("清空失败: %+v", cleared) } } + +func logoFileID(t *testing.T, logo string) uint { + t.Helper() + const prefix = "/api/files/" + if !strings.HasPrefix(logo, prefix) { + t.Fatalf("Logo 地址格式异常: %q", logo) + } + id, err := strconv.ParseUint(strings.TrimPrefix(logo, prefix), 10, 64) + if err != nil || id == 0 { + t.Fatalf("Logo 文件 ID 解析失败: %q", logo) + } + return uint(id) +} + +func refCount(t *testing.T, env *testutil.Env, id uint) int64 { + t.Helper() + var record model.File + if err := env.DB.First(&record, id).Error; err != nil { + t.Fatalf("查询文件 %d 失败: %v", id, err) + } + return record.RefCount +} + +func TestSiteLogoUpload(t *testing.T) { + env := testutil.Setup(t) + admin := env.AdminRouter() + public := env.Router("") + + // 权限:匿名 401、普通用户 403。 + if w := testutil.CallMultipart(t, public, http.MethodPut, "/api/site/logo", "file", "logo.png", testutil.PNG(t, 8, 8)); w.Code != http.StatusUnauthorized { + t.Errorf("匿名上传 Logo 状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + registered := registerUser(t, env) + normal := env.Router(env.Sign(registered.ID)) + if w := testutil.CallMultipart(t, normal, http.MethodPut, "/api/site/logo", "file", "logo.png", testutil.PNG(t, 8, 8)); w.Code != http.StatusForbidden { + t.Errorf("普通用户上传 Logo 状态码 = %d, 期望 %d", w.Code, http.StatusForbidden) + } + + if w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "file", "logo.txt", []byte("not an image")); w.Code != http.StatusBadRequest { + t.Errorf("非图片上传状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String()) + } + + // 首次上传立即生效并占用一次引用。 + w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "file", "logo.png", testutil.PNG(t, 16, 16)) + if w.Code != http.StatusOK { + t.Fatalf("上传 Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + first := decodeSetting(t, w.Body.Bytes()) + firstID := logoFileID(t, first.Logo) + if got := refCount(t, env, firstID); got != 1 { + t.Fatalf("首次上传引用计数 = %d, 期望 1", got) + } + if w := testutil.Call(t, public, http.MethodGet, first.Logo, nil); w.Code != http.StatusOK { + t.Errorf("公开访问 Logo 状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + if w := testutil.Call(t, public, http.MethodGet, "/api/site", nil); w.Code != http.StatusOK { + t.Errorf("读取站点信息状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } else if got := decodeSetting(t, w.Body.Bytes()); got.Logo != first.Logo { + t.Errorf("站点信息 Logo = %q, 期望 %q", got.Logo, first.Logo) + } + + // 相同图片秒传:引用计数不变。 + if w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "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/logo", "file", "new.png", testutil.PNG(t, 24, 24)) + if w.Code != http.StatusOK { + t.Fatalf("更换 Logo 状态码 = %d, body=%s", w.Code, w.Body.String()) + } + second := decodeSetting(t, w.Body.Bytes()) + secondID := logoFileID(t, second.Logo) + if secondID == firstID { + t.Fatal("更换 Logo 应生成新文件") + } + if got := refCount(t, env, firstID); got != 0 { + t.Errorf("旧 Logo 引用计数 = %d, 期望 0", got) + } + if got := refCount(t, env, secondID); got != 1 { + t.Errorf("新 Logo 引用计数 = %d, 期望 1", got) + } + + // 清空 Logo:释放引用并置空。 + w = testutil.Call(t, admin, http.MethodDelete, "/api/site/logo", nil) + if w.Code != http.StatusOK { + t.Fatalf("清空 Logo 状态码 = %d, body=%s", w.Code, w.Body.String()) + } + if cleared := decodeSetting(t, w.Body.Bytes()); cleared.Logo != "" { + t.Errorf("清空后 Logo = %q, 期望空", cleared.Logo) + } + if got := refCount(t, env, secondID); got != 0 { + t.Errorf("清空后引用计数 = %d, 期望 0", got) + } + if w := testutil.Call(t, admin, http.MethodDelete, "/api/site/logo", nil); w.Code != http.StatusOK { + t.Errorf("重复清空状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } +} + +func TestSiteLogoUpdateReference(t *testing.T) { + env := testutil.Setup(t) + admin := env.AdminRouter() + + // 上传一个未被引用的文件,再通过 PUT /site 挂为 Logo 应占用引用。 + w := testutil.CallMultipart(t, admin, http.MethodPost, "/api/files", "file", "logo.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) + } + logoURL := "/api/files/" + strconv.FormatUint(uint64(record.ID), 10) + + if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "logo": logoURL}); w.Code != http.StatusOK { + t.Fatalf("挂载本地 Logo 状态码 = %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", "logo": logoURL}); 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", "logo": "https://example.com/logo.png"}); 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", "logo": "/api/files/9999"}); w.Code != http.StatusBadRequest { + t.Errorf("无效本地 Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String()) + } +} + +func TestSiteLogoSizeLimit(t *testing.T) { + env := testutil.Setup(t) + tooLarge := make([]byte, env.Cfg.MaxUploadBytes()+1) + copy(tooLarge, testutil.PNG(t, 2, 2)) + if w := testutil.CallMultipart(t, env.AdminRouter(), http.MethodPut, "/api/site/logo", "file", "big.png", tooLarge); w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("超限 Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusRequestEntityTooLarge, w.Body.String()) + } +}