Add pack, cfl and safehtml
pack loads local product packs (format 1): t5de-product.json and the product's files, as product.chkn or files/, with index.xml's rating, type, gender and parent product. cfl writes and reads IMVU's product container, uncompressed. safehtml cleans product descriptions by an allowlist (bluemonday). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
9bdc3ff455
16 files changed
+1638
No files matched your search
@@ -0,0 +1,102 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Format is the pack format this package reads.
|
||||
const Format = 1
|
||||
|
||||
// Ratings.
|
||||
const (
|
||||
General = "general"
|
||||
Adult = "adult"
|
||||
)
|
||||
|
||||
// Manifest limits.
|
||||
const (
|
||||
MaxIDLength = 64
|
||||
MaxNameLength = 64 // characters
|
||||
MaxCreatorLength = 64 // characters
|
||||
MaxDescriptionLength = 20000
|
||||
)
|
||||
|
||||
// Manifest is t5de-product.json.
|
||||
type Manifest struct {
|
||||
Format int `json:"format"`
|
||||
ID string `json:"id"`
|
||||
Revision int `json:"revision"`
|
||||
Name string `json:"name"`
|
||||
Creator string `json:"creator,omitempty"`
|
||||
Description string `json:"description,omitempty"` // HTML, cleaned by package safehtml when shown
|
||||
Rating string `json:"rating"`
|
||||
}
|
||||
|
||||
// ParseManifest parses and checks t5de-product.json. Name and Creator come back trimmed.
|
||||
func ParseManifest(data []byte) (*Manifest, error) {
|
||||
m, err := parseManifest(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", ManifestName, err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func parseManifest(data []byte) (*Manifest, error) {
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.DisallowUnknownFields()
|
||||
var m Manifest
|
||||
if err := d.Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := d.Token(); err != io.EOF {
|
||||
return nil, errors.New("data after the object")
|
||||
}
|
||||
m.Name = strings.TrimSpace(m.Name)
|
||||
m.Creator = strings.TrimSpace(m.Creator)
|
||||
switch {
|
||||
case m.Format != Format:
|
||||
return nil, fmt.Errorf("format: is %d, must be %d", m.Format, Format)
|
||||
case !validID(m.ID):
|
||||
return nil, fmt.Errorf("id: %q must be 1-%d of a-z, 0-9 and -, not starting or ending with -", m.ID, MaxIDLength)
|
||||
case m.Revision < 1:
|
||||
return nil, fmt.Errorf("revision: is %d, must be at least 1", m.Revision)
|
||||
case m.Name == "":
|
||||
return nil, errors.New("name: missing")
|
||||
case utf8.RuneCountInString(m.Name) > MaxNameLength:
|
||||
return nil, fmt.Errorf("name: over %d characters", MaxNameLength)
|
||||
case hasControl(m.Name):
|
||||
return nil, errors.New("name: has control characters")
|
||||
case utf8.RuneCountInString(m.Creator) > MaxCreatorLength:
|
||||
return nil, fmt.Errorf("creator: over %d characters", MaxCreatorLength)
|
||||
case hasControl(m.Creator):
|
||||
return nil, errors.New("creator: has control characters")
|
||||
case len(m.Description) > MaxDescriptionLength:
|
||||
return nil, fmt.Errorf("description: over %d bytes", MaxDescriptionLength)
|
||||
case m.Rating != General && m.Rating != Adult:
|
||||
return nil, fmt.Errorf("rating: %q must be %q or %q", m.Rating, General, Adult)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func validID(id string) bool {
|
||||
if id == "" || len(id) > MaxIDLength || id[0] == '-' || id[len(id)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, c := range []byte(id) {
|
||||
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasControl(s string) bool {
|
||||
return strings.ContainsFunc(s, unicode.IsControl)
|
||||
}
|
||||
Reference in new issue
Block a user