Files
foundry-core/internal/platform/content.go
T
codex-bot bfcea1b3af
Foundry Go checks / check (push) Successful in 1m51s
Foundry Go checks / check (pull_request) Successful in 1m56s
Add structured materials, canvas layouts, tags and content permissions
2026-11-05 18:36:53 +08:00

130 lines
3.7 KiB
Go

package platform
import (
"bytes"
"encoding/json"
"io"
"net/url"
"regexp"
)
type Paragraph struct {
ID string `json:"id"`
Text string `json:"text"`
Description string `json:"description,omitempty"`
}
type Section struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Paragraphs []Paragraph `json:"paragraphs"`
}
type Article struct {
Sections []Section `json:"sections"`
}
type Image struct {
URL string `json:"url"`
Alt string `json:"alt,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type CardPart struct {
Kind string `json:"kind"`
Text *string `json:"text,omitempty"`
Article *Article `json:"article,omitempty"`
Image *Image `json:"image,omitempty"`
}
type Content struct {
SchemaVersion int `json:"schema_version"`
Text *string `json:"text,omitempty"`
Article *Article `json:"article,omitempty"`
Image *Image `json:"image,omitempty"`
Parts []CardPart `json:"parts,omitempty"`
}
var blockID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
func validArticle(article *Article) bool {
if article == nil || len(article.Sections) > 200 {
return false
}
seen := map[string]bool{}
total := 0
for _, section := range article.Sections {
if !blockID.MatchString(section.ID) || seen[section.ID] || len(section.Title) > 300 || len(section.Description) > 10000 {
return false
}
seen[section.ID] = true
total += len(section.Paragraphs)
if total > 2000 {
return false
}
for _, paragraph := range section.Paragraphs {
if !blockID.MatchString(paragraph.ID) || seen[paragraph.ID] || len(paragraph.Text) > 100000 || len(paragraph.Description) > 10000 {
return false
}
seen[paragraph.ID] = true
}
}
return true
}
func validImage(image *Image) bool {
if image == nil || len(image.URL) > 2048 || len(image.Alt) > 2000 || image.Width < 0 || image.Width > 100000 || image.Height < 0 || image.Height > 100000 {
return false
}
u, err := url.Parse(image.URL)
return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.Fragment == ""
}
func validPart(part CardPart) bool {
switch part.Kind {
case "text":
return part.Text != nil && len(*part.Text) <= 100000 && part.Article == nil && part.Image == nil
case "article":
return part.Text == nil && part.Image == nil && validArticle(part.Article)
case "image":
return part.Text == nil && part.Article == nil && validImage(part.Image)
default:
return false
}
}
func normalizeContent(kind string, raw json.RawMessage) (json.RawMessage, bool) {
if raw = bytes.TrimSpace(raw); len(raw) == 0 || raw[0] != '{' {
return nil, false
}
var content Content
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if d.Decode(&content) != nil || d.Decode(new(any)) != io.EOF {
return nil, false
}
if content.SchemaVersion == 0 {
content.SchemaVersion = 1
}
if content.SchemaVersion != 1 {
return nil, false
}
switch kind {
case "text", "article", "image":
if len(content.Parts) > 0 || !validPart(CardPart{Kind: kind, Text: content.Text, Article: content.Article, Image: content.Image}) {
return nil, false
}
case "board":
if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) > 0 {
return nil, false
}
case "card":
if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) < 1 || len(content.Parts) > 20 {
return nil, false
}
for _, part := range content.Parts {
if !validPart(part) {
return nil, false
}
}
default:
return nil, false
}
out, err := json.Marshal(content)
return out, err == nil
}