feat: add configurable platform appearance (#385)
This commit is contained in:
1 parent
a6504c0e00
commit
5dd515b8ab
31 files changed
+2795
-153
No files matched your search
@@ -0,0 +1,136 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterAppearanceRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/public/appearance", func(c *gin.Context) {
|
||||
setting, err := svc.Appearance()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
ok(c, gin.H{"appearance": setting})
|
||||
})
|
||||
|
||||
r.GET("/public/appearance/assets/:slot", func(c *gin.Context) {
|
||||
stream, err := svc.OpenAppearanceAsset(c.Param("slot"), c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
defer stream.Body.Close()
|
||||
resource := stream.Resource
|
||||
mimeType := resource.MimeType
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
if c.Query("v") != "" {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
c.Header("Cache-Control", "public, no-cache")
|
||||
}
|
||||
c.Header("Accept-Ranges", "bytes")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
if stream.ContentRange != "" {
|
||||
c.Header("Content-Range", stream.ContentRange)
|
||||
}
|
||||
if seeker, available := stream.Body.(io.ReadSeeker); available {
|
||||
c.Header("Content-Type", mimeType)
|
||||
http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker)
|
||||
return
|
||||
}
|
||||
c.DataFromReader(stream.StatusCode, stream.ContentLength, mimeType, stream.Body, nil)
|
||||
})
|
||||
|
||||
r.GET("/admin/settings/appearance", func(c *gin.Context) {
|
||||
actor, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.AdminAppearance(actor)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
|
||||
r.PATCH("/admin/settings/appearance", func(c *gin.Context) {
|
||||
actor, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
current, err := svc.AdminAppearance(actor)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)
|
||||
req := current.AppearanceSetting
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.UpdateAppearance(actor, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
|
||||
r.DELETE("/admin/settings/appearance", func(c *gin.Context) {
|
||||
actor, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.ResetAppearance(actor)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
|
||||
r.POST("/admin/settings/appearance/assets/:slot", func(c *gin.Context) {
|
||||
actor, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "admin-appearance-upload:"+actor.ID, policy.Request.ResourceUploadPerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
maxBytes, err := service.AppearanceAssetMaxBytes(c.Param("slot"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
resource, err := svc.UploadAppearanceAsset(actor, c.Param("slot"), file)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"resource": resource})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAppearanceRoutesAreRegistered(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
RegisterAppearanceRoutes(router.Group("/api"), &service.Service{})
|
||||
wanted := map[string]bool{
|
||||
"GET /api/public/appearance": false,
|
||||
"GET /api/public/appearance/assets/:slot": false,
|
||||
"GET /api/admin/settings/appearance": false,
|
||||
"PATCH /api/admin/settings/appearance": false,
|
||||
"DELETE /api/admin/settings/appearance": false,
|
||||
"POST /api/admin/settings/appearance/assets/:slot": false,
|
||||
}
|
||||
for _, route := range router.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
if _, exists := wanted[key]; exists {
|
||||
wanted[key] = true
|
||||
}
|
||||
}
|
||||
for route, found := range wanted {
|
||||
if !found {
|
||||
t.Errorf("route %s is not registered", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,15 @@ func (s *Service) DeleteAdminResources(actor *model.User, req AdminResourceDelet
|
||||
blocked.References = appendUniqueAdminResourceReference(blocked.References, AdminResourceReferenceView{Kind: reference.Kind, ID: reference.ID, Title: reference.Title})
|
||||
blockedByID[reference.ResourceID] = blocked
|
||||
}
|
||||
for resourceID, references := range s.appearanceResourceReferences(resourceIDs) {
|
||||
for _, reference := range references {
|
||||
blocked := blockedByID[resourceID]
|
||||
blocked.ID = resourceID
|
||||
blocked.Reason = "资源仍被业务数据引用"
|
||||
blocked.References = appendUniqueAdminResourceReference(blocked.References, reference)
|
||||
blockedByID[resourceID] = blocked
|
||||
}
|
||||
}
|
||||
|
||||
deletable := make([]model.Resource, 0, len(resources))
|
||||
deletableIDs := make([]string, 0, len(resources))
|
||||
|
||||
@@ -69,6 +69,26 @@ func TestDeleteAdminResourcesBlocksAnnouncementImage(t *testing.T) {
|
||||
assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID)
|
||||
}
|
||||
|
||||
func TestDeleteAdminResourcesBlocksDarkAppearanceLogo(t *testing.T) {
|
||||
svc, db, _, admin := newAdminStorageDeleteTestService(t)
|
||||
resource := model.Resource{ID: "appearance-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "appearance/logo.png", MimeType: "image/png"}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, DarkLogoResourceID: resource.ID, LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{resource.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Deleted) != 0 || len(result.Blocked) != 1 || len(result.Blocked[0].References) != 1 || result.Blocked[0].References[0].Kind != "外观" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
assertModelCount(t, db, &model.Resource{}, "id = ?", 1, resource.ID)
|
||||
}
|
||||
|
||||
func TestDeleteAdminResourcesKeepsSharedPhysicalObject(t *testing.T) {
|
||||
svc, db, _, admin := newAdminStorageDeleteTestService(t)
|
||||
resources := []model.Resource{
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const appearanceSettingKey = "appearance"
|
||||
|
||||
const (
|
||||
AppearanceAssetLogo = "logo"
|
||||
AppearanceAssetDarkLogo = "logo-dark"
|
||||
AppearanceAssetVideo = "video"
|
||||
AppearanceAssetPoster = "poster"
|
||||
)
|
||||
|
||||
const (
|
||||
appearanceSchemaVersion = 3
|
||||
appearanceLogoMaxBytes int64 = 5 << 20
|
||||
appearancePosterMaxBytes int64 = 10 << 20
|
||||
appearanceVideoMaxBytes int64 = 256 << 20
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAppearanceBrandName = "影策"
|
||||
defaultAppearanceBrandSlug = "open-ai-canvas"
|
||||
defaultAppearanceSkinID = "classic"
|
||||
defaultAppearanceLogoURL = "/logo.svg"
|
||||
defaultAppearanceVideoURL = "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4"
|
||||
defaultAppearancePosterURL = "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg"
|
||||
defaultAppearanceHeroTitle = "让一个故事,\n从文字走向银幕。"
|
||||
)
|
||||
|
||||
type AppearanceSetting struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
BrandName string `json:"brandName"`
|
||||
BrandSlug string `json:"brandSlug"`
|
||||
AuthHeroTitle string `json:"authHeroTitle"`
|
||||
AuthHeroDescription string `json:"authHeroDescription"`
|
||||
LogoResourceID string `json:"logoResourceId"`
|
||||
DarkLogoResourceID string `json:"darkLogoResourceId"`
|
||||
LogoFrameEnabled bool `json:"logoFrameEnabled"`
|
||||
AuthVideoResourceID string `json:"authVideoResourceId"`
|
||||
AuthVideoPosterResourceID string `json:"authVideoPosterResourceId"`
|
||||
SkinID string `json:"skinId"`
|
||||
}
|
||||
|
||||
type PublicAppearanceSetting struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
BrandName string `json:"brandName"`
|
||||
BrandSlug string `json:"brandSlug"`
|
||||
AuthHeroTitle string `json:"authHeroTitle"`
|
||||
AuthHeroDescription string `json:"authHeroDescription"`
|
||||
LogoURL string `json:"logoUrl"`
|
||||
DarkLogoURL string `json:"darkLogoUrl"`
|
||||
LogoFrameEnabled bool `json:"logoFrameEnabled"`
|
||||
AuthVideoURL string `json:"authVideoUrl"`
|
||||
AuthVideoPosterURL string `json:"authVideoPosterUrl"`
|
||||
SkinID string `json:"skinId"`
|
||||
LogoConfigured bool `json:"logoConfigured"`
|
||||
DarkLogoConfigured bool `json:"darkLogoConfigured"`
|
||||
AuthVideoConfigured bool `json:"authVideoConfigured"`
|
||||
AuthVideoPosterConfigured bool `json:"authVideoPosterConfigured"`
|
||||
Configured bool `json:"configured"`
|
||||
Revision string `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type AdminAppearanceSetting struct {
|
||||
AppearanceSetting
|
||||
Public PublicAppearanceSetting `json:"public"`
|
||||
Configured bool `json:"configured"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
func defaultAppearanceSetting() AppearanceSetting {
|
||||
return AppearanceSetting{
|
||||
SchemaVersion: appearanceSchemaVersion,
|
||||
BrandName: defaultAppearanceBrandName,
|
||||
BrandSlug: defaultAppearanceBrandSlug,
|
||||
AuthHeroTitle: defaultAppearanceHeroTitle,
|
||||
LogoFrameEnabled: true,
|
||||
SkinID: defaultAppearanceSkinID,
|
||||
}
|
||||
}
|
||||
|
||||
func AppearanceAssetMaxBytes(slot string) (int64, error) {
|
||||
switch strings.TrimSpace(slot) {
|
||||
case AppearanceAssetLogo, AppearanceAssetDarkLogo:
|
||||
return appearanceLogoMaxBytes, nil
|
||||
case AppearanceAssetPoster:
|
||||
return appearancePosterMaxBytes, nil
|
||||
case AppearanceAssetVideo:
|
||||
return appearanceVideoMaxBytes, nil
|
||||
default:
|
||||
return 0, BadAuthRequest("外观资源类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Appearance() (*PublicAppearanceSetting, error) {
|
||||
setting, value, err := s.readAppearance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicAppearanceSetting(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminAppearance(actor *model.User) (*AdminAppearanceSetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readAppearance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &AdminAppearanceSetting{
|
||||
AppearanceSetting: value,
|
||||
Public: *publicAppearanceSetting(setting, value),
|
||||
Configured: setting != nil,
|
||||
}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAppearance(actor *model.User, value AppearanceSetting) (*AdminAppearanceSetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.SchemaVersion = appearanceSchemaVersion
|
||||
value.BrandName = strings.TrimSpace(value.BrandName)
|
||||
value.BrandSlug = strings.ToLower(strings.TrimSpace(value.BrandSlug))
|
||||
value.AuthHeroTitle = normalizeAppearanceCopy(value.AuthHeroTitle)
|
||||
value.AuthHeroDescription = normalizeAppearanceCopy(value.AuthHeroDescription)
|
||||
value.LogoResourceID = strings.TrimSpace(value.LogoResourceID)
|
||||
value.DarkLogoResourceID = strings.TrimSpace(value.DarkLogoResourceID)
|
||||
value.AuthVideoResourceID = strings.TrimSpace(value.AuthVideoResourceID)
|
||||
value.AuthVideoPosterResourceID = strings.TrimSpace(value.AuthVideoPosterResourceID)
|
||||
value.SkinID = strings.TrimSpace(value.SkinID)
|
||||
if err := validateAppearanceSetting(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
current, before, err := s.readAppearance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, candidate := range []struct {
|
||||
slot string
|
||||
resourceID string
|
||||
currentID string
|
||||
}{
|
||||
{slot: AppearanceAssetLogo, resourceID: value.LogoResourceID, currentID: before.LogoResourceID},
|
||||
{slot: AppearanceAssetDarkLogo, resourceID: value.DarkLogoResourceID, currentID: before.DarkLogoResourceID},
|
||||
{slot: AppearanceAssetVideo, resourceID: value.AuthVideoResourceID, currentID: before.AuthVideoResourceID},
|
||||
{slot: AppearanceAssetPoster, resourceID: value.AuthVideoPosterResourceID, currentID: before.AuthVideoPosterResourceID},
|
||||
} {
|
||||
if err := s.validateAppearanceResource(actor, candidate.slot, candidate.resourceID, candidate.currentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if current != nil {
|
||||
setting.CreatedAt = current.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "appearance.update", "system_setting", appearanceSettingKey, "更新外观配置", map[string]any{"before": before, "after": value}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.AdminAppearance(actor)
|
||||
}
|
||||
|
||||
func (s *Service) ResetAppearance(actor *model.User) (*AdminAppearanceSetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
_, before, err := s.readAppearance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.DeleteSystemSetting(appearanceSettingKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after := defaultAppearanceSetting()
|
||||
if err := s.appendAdminAudit(actor, "appearance.reset", "system_setting", appearanceSettingKey, "恢复影策默认品牌标识", map[string]any{"before": before, "after": after}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.AdminAppearance(actor)
|
||||
}
|
||||
|
||||
func (s *Service) UploadAppearanceAsset(actor *model.User, slot string, header *multipart.FileHeader) (*model.Resource, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mimeType, err := validateAppearanceUpload(slot, header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// UploadResource otherwise trusts a non-empty declared MIME. Replace it with
|
||||
// the sniffed value so the persisted resource contract matches the bytes.
|
||||
header.Header.Set("Content-Type", mimeType)
|
||||
kind := "image"
|
||||
if slot == AppearanceAssetVideo {
|
||||
kind = "video"
|
||||
}
|
||||
resource, err := s.UploadResource(actor.ID, header, kind, 0, 0, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resource.PublicURL = ""
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func (s *Service) OpenAppearanceAsset(slot string, rangeHeader string) (*ResourceStream, error) {
|
||||
_, value, err := s.readAppearance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourceID := appearanceResourceID(value, slot)
|
||||
if resourceID == "" {
|
||||
return nil, NotFound("未配置该外观资源")
|
||||
}
|
||||
resource, err := s.repo.Resource(resourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, NotFound("外观资源不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAppearanceResourceType(slot, resource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.openResourceRange(resource.UserID, resource, rangeHeader)
|
||||
}
|
||||
|
||||
func (s *Service) appearanceResourceReferences(resourceIDs []string) map[string][]AdminResourceReferenceView {
|
||||
result := make(map[string][]AdminResourceReferenceView)
|
||||
_, value, err := s.readAppearance()
|
||||
if err != nil {
|
||||
// Invalid appearance JSON must fail closed for deletion. The caller turns
|
||||
// this sentinel into a visible blocked reference instead of deleting files.
|
||||
for _, resourceID := range resourceIDs {
|
||||
result[resourceID] = []AdminResourceReferenceView{{Kind: "外观", ID: appearanceSettingKey, Title: "外观配置无法读取"}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
candidates := []struct {
|
||||
resourceID string
|
||||
title string
|
||||
}{
|
||||
{resourceID: value.LogoResourceID, title: "浅色模式品牌 Logo"},
|
||||
{resourceID: value.DarkLogoResourceID, title: "深色模式品牌 Logo"},
|
||||
{resourceID: value.AuthVideoResourceID, title: "登录页品牌视频"},
|
||||
{resourceID: value.AuthVideoPosterResourceID, title: "登录页视频封面"},
|
||||
}
|
||||
wanted := make(map[string]struct{}, len(resourceIDs))
|
||||
for _, resourceID := range resourceIDs {
|
||||
wanted[resourceID] = struct{}{}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.resourceID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := wanted[candidate.resourceID]; exists {
|
||||
result[candidate.resourceID] = appendUniqueAdminResourceReference(result[candidate.resourceID], AdminResourceReferenceView{Kind: "外观", ID: appearanceSettingKey, Title: candidate.title})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, error) {
|
||||
setting, err := s.repo.SystemSetting(appearanceSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, defaultAppearanceSetting(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, AppearanceSetting{}, err
|
||||
}
|
||||
value := defaultAppearanceSetting()
|
||||
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
|
||||
return nil, AppearanceSetting{}, errors.New("外观配置格式无效")
|
||||
}
|
||||
value.SchemaVersion = appearanceSchemaVersion
|
||||
value.BrandName = strings.TrimSpace(value.BrandName)
|
||||
if value.BrandName == "" {
|
||||
value.BrandName = defaultAppearanceBrandName
|
||||
}
|
||||
value.BrandSlug = strings.ToLower(strings.TrimSpace(value.BrandSlug))
|
||||
if value.BrandSlug == "" {
|
||||
value.BrandSlug = defaultAppearanceBrandSlug
|
||||
}
|
||||
value.AuthHeroTitle = normalizeAppearanceCopy(value.AuthHeroTitle)
|
||||
if value.AuthHeroTitle == "" {
|
||||
value.AuthHeroTitle = defaultAppearanceHeroTitle
|
||||
}
|
||||
value.AuthHeroDescription = normalizeAppearanceCopy(value.AuthHeroDescription)
|
||||
value.SkinID = strings.TrimSpace(value.SkinID)
|
||||
if value.SkinID == "" {
|
||||
value.SkinID = defaultAppearanceSkinID
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func validateAppearanceSetting(value AppearanceSetting) error {
|
||||
if value.BrandName == "" || utf8.RuneCountInString(value.BrandName) > 40 {
|
||||
return BadAuthRequest("品牌名称必须为 1 到 40 个字符")
|
||||
}
|
||||
for _, char := range value.BrandName {
|
||||
if unicode.IsControl(char) {
|
||||
return BadAuthRequest("品牌名称不能包含控制字符")
|
||||
}
|
||||
}
|
||||
if !validAppearanceBrandSlug(value.BrandSlug) {
|
||||
return BadAuthRequest("英文品牌标识须为 1 到 48 位小写字母、数字或连字符,且不能以连字符开头或结尾")
|
||||
}
|
||||
if err := validateAppearanceCopy(value.AuthHeroTitle, "登录页主标题", 80, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAppearanceCopy(value.AuthHeroDescription, "登录页说明文案", 160, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if value.SkinID != defaultAppearanceSkinID {
|
||||
return BadAuthRequest("当前版本仅支持经典皮肤")
|
||||
}
|
||||
for _, resourceID := range []string{value.LogoResourceID, value.DarkLogoResourceID, value.AuthVideoResourceID, value.AuthVideoPosterResourceID} {
|
||||
if len(resourceID) > 80 {
|
||||
return BadAuthRequest("外观资源 ID 无效")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAppearanceCopy(value string) string {
|
||||
return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n"))
|
||||
}
|
||||
|
||||
func validAppearanceBrandSlug(value string) bool {
|
||||
if len(value) == 0 || len(value) > 48 || value[0] == '-' || value[len(value)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateAppearanceCopy(value string, label string, maxRunes int, required bool) error {
|
||||
if required && value == "" {
|
||||
return BadAuthRequest(label + "不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(value) > maxRunes {
|
||||
return BadAuthRequest(fmt.Sprintf("%s不能超过 %d 个字符", label, maxRunes))
|
||||
}
|
||||
for _, char := range value {
|
||||
if unicode.IsControl(char) && char != '\n' {
|
||||
return BadAuthRequest(label + "不能包含控制字符")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) validateAppearanceResource(actor *model.User, slot string, resourceID string, currentID string) error {
|
||||
if resourceID == "" {
|
||||
return nil
|
||||
}
|
||||
resource, err := s.repo.Resource(resourceID)
|
||||
if err != nil {
|
||||
return BadAuthRequest("选择的外观资源不存在")
|
||||
}
|
||||
if resourceID != currentID && resource.UserID != actor.ID {
|
||||
return Forbidden("只能使用当前管理员上传的外观资源")
|
||||
}
|
||||
return validateAppearanceResourceType(slot, resource)
|
||||
}
|
||||
|
||||
func validateAppearanceResourceType(slot string, resource *model.Resource) error {
|
||||
if resource == nil || resource.Status != model.ResourceStatusReady {
|
||||
return BadAuthRequest("外观资源尚未上传完成")
|
||||
}
|
||||
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(resource.MimeType, ";")[0]))
|
||||
allowed := appearanceAllowedMIMETypes(slot)
|
||||
if _, exists := allowed[mimeType]; !exists {
|
||||
return BadAuthRequest("外观资源文件类型不受支持")
|
||||
}
|
||||
if slot == AppearanceAssetVideo && resource.Kind != "video" {
|
||||
return BadAuthRequest("登录页品牌视频必须是视频资源")
|
||||
}
|
||||
if slot != AppearanceAssetVideo && resource.Kind != "image" {
|
||||
return BadAuthRequest("Logo 和视频封面必须是图片资源")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAppearanceUpload(slot string, header *multipart.FileHeader) (string, error) {
|
||||
maxBytes, err := AppearanceAssetMaxBytes(slot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if header == nil || header.Size <= 0 || header.Size > maxBytes {
|
||||
return "", BadAuthRequest(fmt.Sprintf("%s大小必须在 %dMB 以内", appearanceAssetLabel(slot), maxBytes>>20))
|
||||
}
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
buffer := make([]byte, 512)
|
||||
read, readErr := file.Read(buffer)
|
||||
if readErr != nil && read == 0 {
|
||||
return "", BadAuthRequest("外观资源内容无法读取")
|
||||
}
|
||||
mimeType := detectAppearanceMIME(slot, buffer[:read], header.Size)
|
||||
if _, exists := appearanceAllowedMIMETypes(slot)[mimeType]; !exists {
|
||||
return "", BadAuthRequest(appearanceAssetLabel(slot) + "文件类型不受支持")
|
||||
}
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
func detectAppearanceMIME(slot string, data []byte, fileSize int64) string {
|
||||
mimeType := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]))
|
||||
if slot != AppearanceAssetVideo || mimeType == "video/mp4" || len(data) < 12 {
|
||||
return mimeType
|
||||
}
|
||||
// Go's generic sniffer only recognises a subset of MP4 compatible brands.
|
||||
// Accept a structurally valid ISO Base Media File Format `ftyp` first box,
|
||||
// which covers common `isom`, `iso2`, `avc1`, `M4V ` and similar MP4 files.
|
||||
boxSize := int64(binary.BigEndian.Uint32(data[:4]))
|
||||
if string(data[4:8]) == "ftyp" && boxSize >= 12 && boxSize%4 == 0 && boxSize <= fileSize {
|
||||
return "video/mp4"
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
|
||||
func appearanceAllowedMIMETypes(slot string) map[string]struct{} {
|
||||
if slot == AppearanceAssetVideo {
|
||||
return map[string]struct{}{"video/mp4": {}, "video/webm": {}}
|
||||
}
|
||||
return map[string]struct{}{"image/png": {}, "image/jpeg": {}, "image/webp": {}}
|
||||
}
|
||||
|
||||
func appearanceAssetLabel(slot string) string {
|
||||
switch slot {
|
||||
case AppearanceAssetLogo:
|
||||
return "浅色模式品牌 Logo"
|
||||
case AppearanceAssetDarkLogo:
|
||||
return "深色模式品牌 Logo"
|
||||
case AppearanceAssetPoster:
|
||||
return "视频封面"
|
||||
case AppearanceAssetVideo:
|
||||
return "品牌视频"
|
||||
default:
|
||||
return "外观资源"
|
||||
}
|
||||
}
|
||||
|
||||
func appearanceResourceID(value AppearanceSetting, slot string) string {
|
||||
switch slot {
|
||||
case AppearanceAssetLogo:
|
||||
return value.LogoResourceID
|
||||
case AppearanceAssetDarkLogo:
|
||||
return value.DarkLogoResourceID
|
||||
case AppearanceAssetVideo:
|
||||
return value.AuthVideoResourceID
|
||||
case AppearanceAssetPoster:
|
||||
return value.AuthVideoPosterResourceID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetting) *PublicAppearanceSetting {
|
||||
revision := "builtin"
|
||||
if setting != nil {
|
||||
revision = strconv.FormatInt(setting.UpdatedAt.UTC().UnixNano(), 36)
|
||||
}
|
||||
result := &PublicAppearanceSetting{
|
||||
SchemaVersion: appearanceSchemaVersion,
|
||||
BrandName: value.BrandName,
|
||||
BrandSlug: value.BrandSlug,
|
||||
AuthHeroTitle: value.AuthHeroTitle,
|
||||
AuthHeroDescription: value.AuthHeroDescription,
|
||||
LogoURL: defaultAppearanceLogoURL,
|
||||
DarkLogoURL: defaultAppearanceLogoURL,
|
||||
LogoFrameEnabled: value.LogoFrameEnabled,
|
||||
AuthVideoURL: defaultAppearanceVideoURL,
|
||||
AuthVideoPosterURL: defaultAppearancePosterURL,
|
||||
SkinID: value.SkinID,
|
||||
Configured: setting != nil,
|
||||
Revision: revision,
|
||||
}
|
||||
if setting != nil {
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
if value.LogoResourceID != "" || value.DarkLogoResourceID != "" {
|
||||
result.LogoConfigured = true
|
||||
if value.LogoResourceID != "" {
|
||||
result.LogoURL = appearanceAssetURL(AppearanceAssetLogo, revision)
|
||||
} else {
|
||||
result.LogoURL = appearanceAssetURL(AppearanceAssetDarkLogo, revision)
|
||||
}
|
||||
if value.DarkLogoResourceID != "" {
|
||||
result.DarkLogoConfigured = true
|
||||
result.DarkLogoURL = appearanceAssetURL(AppearanceAssetDarkLogo, revision)
|
||||
} else {
|
||||
result.DarkLogoURL = result.LogoURL
|
||||
}
|
||||
if value.LogoResourceID == "" {
|
||||
result.LogoURL = result.DarkLogoURL
|
||||
}
|
||||
}
|
||||
if value.AuthVideoResourceID != "" {
|
||||
result.AuthVideoConfigured = true
|
||||
result.AuthVideoURL = appearanceAssetURL(AppearanceAssetVideo, revision)
|
||||
if value.AuthVideoPosterResourceID == "" {
|
||||
// A custom video must not briefly reveal the built-in poster while its
|
||||
// first frame loads. An optional custom poster may be configured below.
|
||||
result.AuthVideoPosterURL = ""
|
||||
}
|
||||
}
|
||||
if value.AuthVideoPosterResourceID != "" {
|
||||
result.AuthVideoPosterConfigured = true
|
||||
result.AuthVideoPosterURL = appearanceAssetURL(AppearanceAssetPoster, revision)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func appearanceAssetURL(slot string, revision string) string {
|
||||
return "/api/public/appearance/assets/" + url.PathEscape(slot) + "?v=" + url.QueryEscape(revision)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) {
|
||||
svc, _, _, _ := newAppearanceTestService(t)
|
||||
|
||||
appearance, err := svc.Appearance()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || appearance.SkinID != defaultAppearanceSkinID {
|
||||
t.Fatalf("Appearance() = %#v", appearance)
|
||||
}
|
||||
if appearance.LogoConfigured || appearance.DarkLogoConfigured || appearance.AuthVideoConfigured || appearance.AuthVideoPosterConfigured || appearance.Revision != "builtin" {
|
||||
t.Fatalf("Appearance() configured state = %#v", appearance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppearanceBackfillsVersionThreeFieldsForExistingSetting(t *testing.T) {
|
||||
svc, db, _, _ := newAppearanceTestService(t)
|
||||
legacy := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: `{"schemaVersion":1,"brandName":"旧品牌","skinId":"classic"}`}
|
||||
if err := db.Create(&legacy).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
appearance, err := svc.Appearance()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled {
|
||||
t.Fatalf("legacy appearance = %#v", appearance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T) {
|
||||
svc, db, _, admin := newAppearanceTestService(t)
|
||||
resources := []model.Resource{
|
||||
{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"},
|
||||
{ID: "brand-logo-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo-dark.png", MimeType: "image/png"},
|
||||
{ID: "brand-video", UserID: admin.ID, Kind: "video", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/video.mp4", MimeType: "video/mp4"},
|
||||
{ID: "brand-poster", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/poster.jpg", MimeType: "image/jpeg"},
|
||||
}
|
||||
if err := db.Create(&resources).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateAppearance(admin, AppearanceSetting{
|
||||
BrandName: "HIMA Studio",
|
||||
BrandSlug: "hima-studio",
|
||||
AuthHeroTitle: "把灵感,\n变成可见的故事。",
|
||||
AuthHeroDescription: "从画布开始,持续推进你的创作。",
|
||||
LogoResourceID: "brand-logo",
|
||||
DarkLogoResourceID: "brand-logo-dark",
|
||||
LogoFrameEnabled: false,
|
||||
AuthVideoResourceID: "brand-video",
|
||||
AuthVideoPosterResourceID: "brand-poster",
|
||||
SkinID: defaultAppearanceSkinID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured {
|
||||
t.Fatalf("UpdateAppearance() = %#v", updated)
|
||||
}
|
||||
for _, assetURL := range []string{updated.Public.LogoURL, updated.Public.DarkLogoURL, updated.Public.AuthVideoURL, updated.Public.AuthVideoPosterURL} {
|
||||
if !strings.HasPrefix(assetURL, "/api/public/appearance/assets/") || !strings.Contains(assetURL, "?v=") {
|
||||
t.Fatalf("versioned asset URL = %q", assetURL)
|
||||
}
|
||||
}
|
||||
encodedPublic, err := json.Marshal(updated.Public)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encodedPublic), "brand-logo") || strings.Contains(string(encodedPublic), "brand-logo-dark") || strings.Contains(string(encodedPublic), "brand-video") || strings.Contains(string(encodedPublic), "brand-poster") {
|
||||
t.Fatalf("public appearance leaked resource IDs: %s", encodedPublic)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "appearance.update").Count(&auditCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("audit count = %d, want 1", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) {
|
||||
svc, db, _, admin := newAppearanceTestService(t)
|
||||
resources := []model.Resource{
|
||||
{ID: "only-light", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/light.png", MimeType: "image/png"},
|
||||
{ID: "only-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/dark.png", MimeType: "image/png"},
|
||||
}
|
||||
if err := db.Create(&resources).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lightOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: "only-light", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lightOnly.Public.LogoURL != lightOnly.Public.DarkLogoURL || !lightOnly.Public.LogoConfigured || lightOnly.Public.DarkLogoConfigured {
|
||||
t.Fatalf("light-only appearance = %#v", lightOnly.Public)
|
||||
}
|
||||
|
||||
darkOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, DarkLogoResourceID: "only-dark", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if darkOnly.Public.LogoURL != darkOnly.Public.DarkLogoURL || !darkOnly.Public.LogoConfigured || !darkOnly.Public.DarkLogoConfigured {
|
||||
t.Fatalf("dark-only appearance = %#v", darkOnly.Public)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAppearanceRestoresBuiltInBrandWithoutDeletingResources(t *testing.T) {
|
||||
svc, db, _, admin := newAppearanceTestService(t)
|
||||
resource := model.Resource{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UpdateAppearance(admin, AppearanceSetting{
|
||||
BrandName: "HIMA Studio",
|
||||
BrandSlug: "hima-studio",
|
||||
AuthHeroTitle: "把灵感变成故事",
|
||||
LogoResourceID: resource.ID,
|
||||
SkinID: defaultAppearanceSkinID,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reset, err := svc.ResetAppearance(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reset.Configured || reset.BrandName != defaultAppearanceBrandName || reset.BrandSlug != defaultAppearanceBrandSlug || reset.AuthHeroTitle != defaultAppearanceHeroTitle || reset.LogoResourceID != "" || reset.DarkLogoResourceID != "" || !reset.LogoFrameEnabled || reset.Public.Revision != "builtin" || reset.Public.LogoConfigured || reset.Public.DarkLogoConfigured {
|
||||
t.Fatalf("ResetAppearance() = %#v", reset)
|
||||
}
|
||||
var resourceCount int64
|
||||
if err := db.Model(&model.Resource{}).Where("id = ?", resource.ID).Count(&resourceCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resourceCount != 1 {
|
||||
t.Fatalf("resource count = %d, want 1", resourceCount)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "appearance.reset").Count(&auditCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("reset audit count = %d, want 1", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppearanceRejectsAnotherUsersNewResource(t *testing.T) {
|
||||
svc, db, _, admin := newAppearanceTestService(t)
|
||||
resource := model.Resource{ID: "foreign-logo", UserID: "another-admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: resource.ID, SkinID: defaultAppearanceSkinID})
|
||||
if err == nil || !strings.Contains(err.Error(), "当前管理员上传") {
|
||||
t.Fatalf("UpdateAppearance() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAppearanceAssetOnlyServesConfiguredSlot(t *testing.T) {
|
||||
svc, db, dataDir, admin := newAppearanceTestService(t)
|
||||
resource := model.Resource{ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("brand-bytes"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: resource.ID, SkinID: defaultAppearanceSkinID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stream, err := svc.OpenAppearanceAsset(AppearanceAssetLogo, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream.Body.Close()
|
||||
if stream.Resource.ID != resource.ID {
|
||||
t.Fatalf("served resource = %q", stream.Resource.ID)
|
||||
}
|
||||
if _, err := svc.OpenAppearanceAsset(AppearanceAssetVideo, ""); err == nil {
|
||||
t.Fatal("unconfigured video slot unexpectedly served a resource")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppearanceValidatesLoginCopy(t *testing.T) {
|
||||
svc, _, _, admin := newAppearanceTestService(t)
|
||||
|
||||
_, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: "", SkinID: defaultAppearanceSkinID})
|
||||
if err == nil || !strings.Contains(err.Error(), "主标题") {
|
||||
t.Fatalf("empty title error = %v", err)
|
||||
}
|
||||
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, AuthHeroDescription: strings.Repeat("文", 161), SkinID: defaultAppearanceSkinID})
|
||||
if err == nil || !strings.Contains(err.Error(), "160") {
|
||||
t.Fatalf("long description error = %v", err)
|
||||
}
|
||||
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: "标题\t注入", SkinID: defaultAppearanceSkinID})
|
||||
if err == nil || !strings.Contains(err.Error(), "控制字符") {
|
||||
t.Fatalf("control character error = %v", err)
|
||||
}
|
||||
_, err = svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "HIMA Studio", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: defaultAppearanceSkinID})
|
||||
if err == nil || !strings.Contains(err.Error(), "英文品牌标识") {
|
||||
t.Fatalf("invalid slug error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppearanceUploadSniffsBytesInsteadOfDeclaredMIME(t *testing.T) {
|
||||
pngHeader := multipartFileHeader(t, "logo.png", "text/plain", append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...))
|
||||
if mimeType, err := validateAppearanceUpload(AppearanceAssetLogo, pngHeader); err != nil || mimeType != "image/png" {
|
||||
t.Fatalf("validateAppearanceUpload(png) = %q, %v", mimeType, err)
|
||||
}
|
||||
darkPNGHeader := multipartFileHeader(t, "logo-dark.png", "text/plain", append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...))
|
||||
if mimeType, err := validateAppearanceUpload(AppearanceAssetDarkLogo, darkPNGHeader); err != nil || mimeType != "image/png" {
|
||||
t.Fatalf("validateAppearanceUpload(dark png) = %q, %v", mimeType, err)
|
||||
}
|
||||
fakeImage := multipartFileHeader(t, "fake.png", "image/png", []byte("not an image"))
|
||||
if _, err := validateAppearanceUpload(AppearanceAssetLogo, fakeImage); err == nil {
|
||||
t.Fatal("declared image MIME accepted non-image bytes")
|
||||
}
|
||||
mp4Bytes := append([]byte{0x00, 0x00, 0x00, 0x14}, []byte("ftypisom\x00\x00\x00\x00isom")...)
|
||||
mp4Header := multipartFileHeader(t, "hero.mp4", "application/octet-stream", mp4Bytes)
|
||||
if mimeType, err := validateAppearanceUpload(AppearanceAssetVideo, mp4Header); err != nil || mimeType != "video/mp4" {
|
||||
t.Fatalf("validateAppearanceUpload(mp4) = %q, %v", mimeType, err)
|
||||
}
|
||||
fakeMP4 := multipartFileHeader(t, "fake.mp4", "video/mp4", append([]byte{0x00, 0x00, 0x10, 0x00}, []byte("ftypisom")...))
|
||||
if _, err := validateAppearanceUpload(AppearanceAssetVideo, fakeMP4); err == nil {
|
||||
t.Fatal("invalid MP4 box size accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func multipartFileHeader(t *testing.T, name string, contentType string, data []byte) *multipart.FileHeader {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="file"; filename="`+name+`"`)
|
||||
header.Set("Content-Type", contentType)
|
||||
part, err := writer.CreatePart(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
if err := req.ParseMultipartForm(int64(body.Len()) + 1024); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req.MultipartForm.File["file"][0]
|
||||
}
|
||||
|
||||
func newAppearanceTestService(t *testing.T) (*Service, *gorm.DB, string, *model.User) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.MigrateSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := &model.User{ID: "appearance-admin", Username: "appearance-admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dataDir := t.TempDir()
|
||||
return New(repository.New(db), dataDir), db, dataDir, admin
|
||||
}
|
||||
Reference in new issue
Block a user