feat: add configurable platform appearance (#385)

This commit is contained in:
likeraylee authored and GitHub committed 2026-09-02 20:33:25 +08:00
1 parent a6504c0e00
commit 5dd515b8ab
31 files changed
+2795 -153

No files matched your search

+136
View File
@@ -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{
+561
View File
@@ -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)
}
+299
View File
@@ -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
}