后台管理支持上传与清空网站 Logo

- 新增 PUT /api/site/logo(multipart、仅图片,立即生效)与 DELETE /api/site/logo,均仅管理员可用
- Logo 文件引用计数自动管理:上传占用、替换/清空/切换外链释放旧文件;PUT /site 手填地址同样处理,挂载不存在的本地文件返回 400
- 抽出 file.IsImageUpload 供头像与 Logo 共用(按文件头探测图片)
- 后台管理 Logo 区保留外链输入,新增上传/清空按钮(无裁剪,校验类型与大小),三语文案补齐
- 补充引用计数、权限与校验测试并重新生成 Swagger 文档
This commit is contained in:
2026-09-21 21:10:23 +08:00
parent 9bf397064d
commit 067ade546f
14 files changed
+775 -41

No files matched your search

+110 -2
View File
@@ -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": [
+110 -2
View File
@@ -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": [
+76 -3
View File
@@ -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
+13
View File
@@ -23,3 +23,16 @@ export function updateSiteInfo(payload: UpdateSitePayload): Promise<SiteInfo> {
body: JSON.stringify(payload),
})
}
export function uploadSiteLogo(file: Blob, filename = 'logo.png'): Promise<SiteInfo> {
const form = new FormData()
form.append('file', file, filename)
return request<SiteInfo>('/site/logo', {
method: 'PUT',
body: form,
})
}
export function deleteSiteLogo(): Promise<SiteInfo> {
return request<SiteInfo>('/site/logo', { method: 'DELETE' })
}
+9 -1
View File
@@ -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',
+9 -1
View File
@@ -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: 'ネットワークエラーが発生しました。後でもう一度お試しください',
+9 -1
View File
@@ -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: '网络异常,请稍后重试',
+21 -2
View File
@@ -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<SiteInfo> {
const info = await uploadSiteLogoRequest(file)
apply(info)
return info
}
async function removeLogo(): Promise<SiteInfo> {
const info = await deleteSiteLogoRequest()
apply(info)
return info
}
return { siteName, logo, footer, name, hasLogo, load, save, uploadLogo, removeLogo }
})
+94
View File
@@ -45,8 +45,71 @@ const [footer, footerProps] = defineField('footer')
const submitError = ref('')
const saved = ref(false)
const logoInput = ref<HTMLInputElement | null>(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
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
:disabled="logoBusy"
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="logoInput?.click()"
>
{{ logoBusy ? t('common.submitting') : t('admin.logoUpload') }}
</button>
<button
v-if="logoPreview"
type="button"
:disabled="logoBusy"
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="onLogoClear"
>
{{ t('admin.logoClear') }}
</button>
<input
ref="logoInput"
type="file"
accept="image/*"
class="hidden"
@change="onLogoSelected"
/>
</div>
<p v-if="logoError" class="mt-2 text-xs text-red-500 dark:text-red-400">
{{ logoError }}
</p>
<p v-else-if="logoNotice" class="mt-2 text-xs text-primary">{{ logoNotice }}</p>
<p class="mt-2 text-xs text-content-3">{{ t('admin.logoHint') }}</p>
</div>
+3 -1
View File
@@ -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")
{
+1 -21
View File
@@ -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
}
+17
View File
@@ -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)
+148 -7
View File
@@ -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
}
+155
View File
@@ -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())
}
}