后台管理支持上传与清空网站 Logo
- 新增 PUT /api/site/logo(multipart、仅图片,立即生效)与 DELETE /api/site/logo,均仅管理员可用 - Logo 文件引用计数自动管理:上传占用、替换/清空/切换外链释放旧文件;PUT /site 手填地址同样处理,挂载不存在的本地文件返回 400 - 抽出 file.IsImageUpload 供头像与 Logo 共用(按文件头探测图片) - 后台管理 Logo 区保留外链输入,新增上传/清空按钮(无裁剪,校验类型与大小),三语文案补齐 - 补充引用计数、权限与校验测试并重新生成 Swagger 文档
This commit is contained in:
14 files changed
+775
-41
No files matched your search
+110
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
|
||||
@@ -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: '网络异常,请稍后重试',
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user