Files
ttstd-infinite-canvas/backend/internal/app/appearance_updates.go

220 lines
6.9 KiB
Go

package app
import (
"net/url"
"strings"
"time"
"yingce/backend/internal/assets"
"yingce/backend/internal/model"
)
type UpdateAnnouncement struct {
Enabled bool `json:"enabled"`
CurrentVersion string `json:"currentVersion"`
Releases []UpdateRelease `json:"releases"`
}
type UpdateRelease struct {
ID string `json:"id"`
Version string `json:"version"`
Title string `json:"title"`
Date string `json:"date"`
Blocks []UpdateBlock `json:"blocks"`
}
type UpdateBlock struct {
ID string `json:"id"`
Type string `json:"type"`
Text string `json:"text"`
ResourceID string `json:"resourceId"`
Caption string `json:"caption"`
Layout string `json:"layout"`
MediaURL string `json:"mediaUrl,omitempty"`
}
func normalizeUpdateAnnouncement(value UpdateAnnouncement) (UpdateAnnouncement, error) {
value.CurrentVersion = normalizeAppearanceSingleLine(value.CurrentVersion)
if err := validateAppearanceCopy(value.CurrentVersion, "公告当前版本", 40, value.Enabled); err != nil {
return value, err
}
if len(value.Releases) > 30 {
return value, BadAuthRequest("更新公告最多 30 个版本")
}
if value.Enabled && len(value.Releases) == 0 {
return value, BadAuthRequest("开启自定义更新公告前请添加版本和内容")
}
seen := map[string]bool{}
versions := map[string]bool{}
total := 0
currentFound := false
for i := range value.Releases {
r := &value.Releases[i]
r.ID, r.Version = strings.TrimSpace(r.ID), normalizeAppearanceSingleLine(r.Version)
r.Title, r.Date = normalizeAppearanceSingleLine(r.Title), strings.TrimSpace(r.Date)
if r.ID == "" || len(r.ID) > 80 || seen[r.ID] {
return value, BadAuthRequest("公告版本标识无效或重复")
}
seen[r.ID] = true
if err := validateAppearanceCopy(r.Version, "公告版本号", 40, true); err != nil {
return value, err
}
if versions[r.Version] {
return value, BadAuthRequest("公告版本号不能重复")
}
versions[r.Version] = true
if r.Version == value.CurrentVersion {
currentFound = true
}
if err := validateAppearanceCopy(r.Title, "公告标题", 100, false); err != nil {
return value, err
}
if r.Date != "" {
if _, err := time.Parse("2006-01-02", r.Date); err != nil {
return value, BadAuthRequest("公告日期须为 YYYY-MM-DD")
}
}
if len(r.Blocks) == 0 || len(r.Blocks) > 50 {
return value, BadAuthRequest("每个公告版本须包含 1 至 50 个内容块")
}
for j := range r.Blocks {
b := &r.Blocks[j]
b.ID, b.ResourceID = strings.TrimSpace(b.ID), strings.TrimSpace(b.ResourceID)
b.Text, b.Caption, b.MediaURL = normalizeAppearanceCopy(b.Text), normalizeAppearanceSingleLine(b.Caption), ""
if b.ID == "" || len(b.ID) > 80 || seen[b.ID] {
return value, BadAuthRequest("公告内容块标识无效或重复")
}
seen[b.ID] = true
if b.Layout == "" {
b.Layout = "full"
}
if b.Layout != "full" && b.Layout != "half" {
return value, BadAuthRequest("公告布局须为整行或半行")
}
if err := validateAppearanceCopy(b.Text, "公告正文", 10000, b.Type == "text"); err != nil {
return value, err
}
if err := validateAppearanceCopy(b.Caption, "媒体说明", 200, false); err != nil {
return value, err
}
switch b.Type {
case "text":
b.ResourceID = ""
case "image", "video":
if b.ResourceID == "" || len(b.ResourceID) > 80 {
return value, BadAuthRequest("请上传公告图片或视频")
}
b.Text = ""
default:
return value, BadAuthRequest("公告内容块类型无效")
}
total += len(b.Text) + len(b.Caption)
}
}
if total > 200000 {
return value, BadAuthRequest("公告内容总大小不能超过 200KB")
}
if value.Enabled && !currentFound {
return value, BadAuthRequest("公告当前版本必须对应已添加的版本")
}
return value, nil
}
func updateResourceSlot(kind string) string {
if kind == "video" {
return AppearanceAssetUpdateVideo
}
return AppearanceAssetUpdateImage
}
func updateReferenced(value UpdateAnnouncement, id string) bool {
for _, release := range value.Releases {
for _, block := range release.Blocks {
if block.ResourceID == id && (block.Type == "image" || block.Type == "video") {
return true
}
}
}
return false
}
func (s *Service) validateUpdateAnnouncementResources(actor *model.User, value, before UpdateAnnouncement) error {
for _, release := range value.Releases {
for _, block := range release.Blocks {
if block.ResourceID == "" {
continue
}
current := ""
if updateReferenced(before, block.ResourceID) {
current = block.ResourceID
}
slot := updateResourceSlot(block.Type)
if err := s.validateAppearanceResource(actor, slot, block.ResourceID, current); err != nil {
return err
}
resource, err := s.repo.Resource(block.ResourceID)
if err != nil {
return err
}
maxBytes, _ := AppearanceAssetMaxBytes(slot)
if resource.Size <= 0 || resource.Size > maxBytes {
return BadAuthRequest("公告媒体大小超过允许范围")
}
}
}
return nil
}
func publicUpdateAnnouncement(value UpdateAnnouncement, revision string) UpdateAnnouncement {
if !value.Enabled {
return UpdateAnnouncement{Releases: []UpdateRelease{}}
}
result := value
result.Releases = append([]UpdateRelease(nil), value.Releases...)
for i := range result.Releases {
result.Releases[i].Blocks = append([]UpdateBlock(nil), value.Releases[i].Blocks...)
for j := range result.Releases[i].Blocks {
b := &result.Releases[i].Blocks[j]
if b.ResourceID != "" {
b.MediaURL = "/api/public/appearance/updates/" + url.PathEscape(b.ResourceID) + "?v=" + url.QueryEscape(revision)
}
}
}
return result
}
// Public delivery is limited to the currently enabled announcement references.
// Draft previews require an administrator and never expose unrelated resources.
func (s *Service) PrepareUpdateAnnouncementDelivery(actor *model.User, id string, options ResourceAccessOptions, rangeHeader string) (*ResourceDelivery, error) {
if actor != nil {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
}
_, value, err := s.readAppearance()
if err != nil {
return nil, err
}
referenced := updateReferenced(value.Updates, id)
if actor == nil && (!value.Updates.Enabled || !referenced) {
return nil, NotFound("公告媒体未发布")
}
resource, err := s.repo.Resource(id)
if err != nil {
return nil, NotFound("公告媒体不存在")
}
if actor != nil && resource.UserID != actor.ID && !referenced {
return nil, Forbidden("不能预览其他管理员的未发布媒体")
}
if resource.Kind != "image" && resource.Kind != "video" {
return nil, BadAuthRequest("公告仅支持图片和视频")
}
if err := validateAppearanceResourceType(updateResourceSlot(resource.Kind), resource); err != nil {
return nil, err
}
if options.Purpose == "" {
options.Purpose = assets.PurposeDisplay
}
return s.prepareResourceDelivery(resource.UserID, resource, options, rangeHeader)
}