334 lines
7.1 KiB
Go
334 lines
7.1 KiB
Go
package entity
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Content is the in-memory message body. Persist only Marshal() bytes into messages.content.
|
|
type Content struct {
|
|
Type Type `json:"t"`
|
|
ReplyTo int64 `json:"reply_to,omitempty"`
|
|
AtAll bool `json:"at_all,omitempty"`
|
|
AtUIDs []int64 `json:"at_uids,omitempty"`
|
|
Text *Text `json:"text,omitempty"`
|
|
Image *Image `json:"image,omitempty"`
|
|
Voice *Voice `json:"voice,omitempty"`
|
|
Video *Video `json:"video,omitempty"`
|
|
Emoji *Emoji `json:"emoji,omitempty"`
|
|
GIF *GIF `json:"gif,omitempty"`
|
|
File *File `json:"file,omitempty"`
|
|
Location *Location `json:"location,omitempty"`
|
|
System *System `json:"system,omitempty"`
|
|
}
|
|
|
|
func NewText(body string) *Content {
|
|
return &Content{Type: TypeText, Text: &Text{Body: body}}
|
|
}
|
|
|
|
func NewImage(img *Image) *Content {
|
|
return &Content{Type: TypeImage, Image: img}
|
|
}
|
|
|
|
func NewVoice(v *Voice) *Content {
|
|
return &Content{Type: TypeVoice, Voice: v}
|
|
}
|
|
|
|
func NewVideo(v *Video) *Content {
|
|
return &Content{Type: TypeVideo, Video: v}
|
|
}
|
|
|
|
func NewEmoji(e *Emoji) *Content {
|
|
return &Content{Type: TypeEmoji, Emoji: e}
|
|
}
|
|
|
|
func NewGIF(g *GIF) *Content {
|
|
return &Content{Type: TypeGIF, GIF: g}
|
|
}
|
|
|
|
func NewFile(f *File) *Content {
|
|
return &Content{Type: TypeFile, File: f}
|
|
}
|
|
|
|
func NewLocation(loc *Location) *Content {
|
|
return &Content{Type: TypeLocation, Location: loc}
|
|
}
|
|
|
|
func NewSystem(s *System) *Content {
|
|
return &Content{Type: TypeSystem, System: s}
|
|
}
|
|
|
|
// Marshal serializes content to JSON bytes for the BLOB column. It never returns a Go string.
|
|
func (c *Content) Marshal() ([]byte, error) {
|
|
if err := c.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(c); err != nil {
|
|
return nil, fmt.Errorf("content: %w", err)
|
|
}
|
|
b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'})
|
|
if len(b) > MaxContentBytes {
|
|
return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// UnmarshalContent decodes stored BLOB bytes. Rejects a plain UTF-8 string that is not JSON.
|
|
func UnmarshalContent(raw []byte) (*Content, error) {
|
|
if len(raw) == 0 {
|
|
return nil, fmt.Errorf("content: empty")
|
|
}
|
|
if len(raw) > MaxContentBytes {
|
|
return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes)
|
|
}
|
|
var c Content
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
return nil, fmt.Errorf("content: %w", err)
|
|
}
|
|
if err := c.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func (c *Content) Validate() error {
|
|
if c == nil {
|
|
return fmt.Errorf("content: empty")
|
|
}
|
|
if !c.Type.Valid() {
|
|
return fmt.Errorf("content: unknown type %d", c.Type)
|
|
}
|
|
if c.ReplyTo < 0 {
|
|
return fmt.Errorf("content: reply_to")
|
|
}
|
|
if err := validateMentions(c); err != nil {
|
|
return err
|
|
}
|
|
got, n := c.populated()
|
|
if n != 1 {
|
|
return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n)
|
|
}
|
|
if got != c.Type {
|
|
return fmt.Errorf("content: type %d does not match payload %d", c.Type, got)
|
|
}
|
|
switch c.Type {
|
|
case TypeText:
|
|
return validateText(c.Text)
|
|
case TypeImage:
|
|
return validateMediaKey("image", c.Image.Key)
|
|
case TypeVoice:
|
|
return validateVoice(c.Voice)
|
|
case TypeVideo:
|
|
return validateMediaKey("video", c.Video.Key)
|
|
case TypeEmoji:
|
|
return validateEmoji(c.Emoji)
|
|
case TypeGIF:
|
|
return validateMediaKey("gif", c.GIF.Key)
|
|
case TypeFile:
|
|
return validateFile(c.File)
|
|
case TypeLocation:
|
|
return validateLocation(c.Location)
|
|
case TypeSystem:
|
|
return validateSystem(c.System)
|
|
default:
|
|
return fmt.Errorf("content: unknown type %d", c.Type)
|
|
}
|
|
}
|
|
|
|
func (c *Content) populated() (Type, int) {
|
|
n := 0
|
|
var t Type
|
|
if c.Text != nil {
|
|
n++
|
|
t = TypeText
|
|
}
|
|
if c.Image != nil {
|
|
n++
|
|
t = TypeImage
|
|
}
|
|
if c.Voice != nil {
|
|
n++
|
|
t = TypeVoice
|
|
}
|
|
if c.Video != nil {
|
|
n++
|
|
t = TypeVideo
|
|
}
|
|
if c.Emoji != nil {
|
|
n++
|
|
t = TypeEmoji
|
|
}
|
|
if c.GIF != nil {
|
|
n++
|
|
t = TypeGIF
|
|
}
|
|
if c.File != nil {
|
|
n++
|
|
t = TypeFile
|
|
}
|
|
if c.Location != nil {
|
|
n++
|
|
t = TypeLocation
|
|
}
|
|
if c.System != nil {
|
|
n++
|
|
t = TypeSystem
|
|
}
|
|
return t, n
|
|
}
|
|
|
|
func validateText(t *Text) error {
|
|
body := strings.TrimSpace(t.Body)
|
|
if body == "" {
|
|
return fmt.Errorf("content: text empty")
|
|
}
|
|
if !utf8.ValidString(t.Body) {
|
|
return fmt.Errorf("content: text not utf-8")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateMentions(c *Content) error {
|
|
if c.Type == TypeSystem && (c.AtAll || len(c.AtUIDs) > 0) {
|
|
return fmt.Errorf("content: mentions")
|
|
}
|
|
if len(c.AtUIDs) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[int64]struct{}, len(c.AtUIDs))
|
|
out := make([]int64, 0, len(c.AtUIDs))
|
|
for _, id := range c.AtUIDs {
|
|
if id <= 0 {
|
|
return fmt.Errorf("content: at_uids")
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
if len(out) > MaxAtUIDs {
|
|
return fmt.Errorf("content: at_uids")
|
|
}
|
|
c.AtUIDs = out
|
|
return nil
|
|
}
|
|
|
|
func validateMediaKey(kind, key string) error {
|
|
if strings.TrimSpace(key) == "" {
|
|
return fmt.Errorf("content: %s key empty", kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateVoice(v *Voice) error {
|
|
if err := validateMediaKey("voice", v.Key); err != nil {
|
|
return err
|
|
}
|
|
if v.DurationMS < 0 {
|
|
return fmt.Errorf("content: voice duration")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateEmoji(e *Emoji) error {
|
|
if strings.TrimSpace(e.Pack) == "" || strings.TrimSpace(e.ID) == "" {
|
|
return fmt.Errorf("content: emoji pack/id empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFile(f *File) error {
|
|
if err := validateMediaKey("file", f.Key); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(f.Name) == "" {
|
|
return fmt.Errorf("content: file name empty")
|
|
}
|
|
if f.Size < 0 {
|
|
return fmt.Errorf("content: file size")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateLocation(l *Location) error {
|
|
if l.Lat < -90 || l.Lat > 90 || l.Lng < -180 || l.Lng > 180 {
|
|
return fmt.Errorf("content: location range")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSystem(s *System) error {
|
|
if strings.TrimSpace(s.Kind) == "" {
|
|
return fmt.Errorf("content: system kind empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ObjectKeys returns media object keys in this payload (image/voice/video/gif/file).
|
|
func (c *Content) ObjectKeys() []string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
var keys []string
|
|
add := func(k string) {
|
|
k = strings.TrimSpace(k)
|
|
if k != "" {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
switch {
|
|
case c.Image != nil:
|
|
add(c.Image.Key)
|
|
add(c.Image.ThumbKey)
|
|
case c.Voice != nil:
|
|
add(c.Voice.Key)
|
|
case c.Video != nil:
|
|
add(c.Video.Key)
|
|
add(c.Video.CoverKey)
|
|
case c.GIF != nil:
|
|
add(c.GIF.Key)
|
|
case c.File != nil:
|
|
add(c.File.Key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// ReplaceObjectKeys rewrites keys after CopyObject into perm/fav/.
|
|
func (c *Content) ReplaceObjectKeys(mapping map[string]string) {
|
|
if c == nil || len(mapping) == 0 {
|
|
return
|
|
}
|
|
repl := func(s *string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
if n, ok := mapping[*s]; ok {
|
|
*s = n
|
|
}
|
|
}
|
|
if c.Image != nil {
|
|
repl(&c.Image.Key)
|
|
repl(&c.Image.ThumbKey)
|
|
}
|
|
if c.Voice != nil {
|
|
repl(&c.Voice.Key)
|
|
}
|
|
if c.Video != nil {
|
|
repl(&c.Video.Key)
|
|
repl(&c.Video.CoverKey)
|
|
}
|
|
if c.GIF != nil {
|
|
repl(&c.GIF.Key)
|
|
}
|
|
if c.File != nil {
|
|
repl(&c.File.Key)
|
|
}
|
|
}
|