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

295 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"archive/zip"
"bytes"
"encoding/json"
"image/png"
"io"
"mime/multipart"
"path"
"strings"
"unicode"
"yingce/backend/internal/model"
)
const Live2DMaxBytes int64 = 128 << 20
const live2DMaxExpanded uint64 = 384 << 20
const live2DMaxFileBytes int64 = 128 << 20
type Live2DImport struct {
ResourceID string `json:"resourceId"`
Entry string `json:"entry"`
}
type live2DNamedFile struct {
Name string `json:"Name"`
File string `json:"File"`
}
type live2DMotion struct {
File string `json:"File"`
Sound string `json:"Sound"`
FadeInTime float64 `json:"FadeInTime"`
FadeOutTime float64 `json:"FadeOutTime"`
}
type live2DReferences struct {
Moc string `json:"Moc"`
Textures []string `json:"Textures"`
Physics string `json:"Physics"`
Pose string `json:"Pose"`
DisplayInfo string `json:"DisplayInfo"`
UserData string `json:"UserData"`
Expressions []live2DNamedFile `json:"Expressions"`
Motions map[string][]live2DMotion `json:"Motions"`
}
func live2DFileName(name string) bool {
return name != "" && len(name) <= 240 && path.Clean(name) == name && !strings.HasPrefix(name, "/") && name != ".." && !strings.HasPrefix(name, "../") && !strings.ContainsAny(name, "\\:%?#") && strings.IndexFunc(name, unicode.IsControl) == -1
}
func readLive2DFile(file *zip.File, max int64) ([]byte, error) {
if file.UncompressedSize64 > uint64(max) {
return nil, BadAuthRequest("Live2D 文件超过大小限制")
}
r, err := file.Open()
if err != nil {
return nil, err
}
defer r.Close()
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, BadAuthRequest("Live2D 文件损坏")
}
if int64(len(data)) > max {
return nil, BadAuthRequest("Live2D 文件超过大小限制")
}
return data, nil
}
// Never extract uploaded paths to disk. Only validated data files are served from the archive.
func validateLive2DArchive(reader io.ReaderAt, size int64) (string, error) {
if size <= 0 || size > Live2DMaxBytes {
return "", BadAuthRequest("Live2D ZIP 须为非空文件且不超过 128 MiB")
}
z, err := zip.NewReader(reader, size)
if err != nil {
return "", BadAuthRequest("无法读取 Live2D ZIP")
}
if len(z.File) > 256 {
return "", BadAuthRequest("Live2D ZIP 最多 256 个条目")
}
files := map[string]*zip.File{}
folded := map[string]bool{}
entry := ""
var total uint64
var texturePixels int64
for _, f := range z.File {
name := strings.TrimSuffix(f.Name, "/")
if !live2DFileName(name) {
return "", BadAuthRequest("模型包包含非法路径")
}
if folded[strings.ToLower(name)] {
return "", BadAuthRequest("模型包包含重复路径")
}
folded[strings.ToLower(name)] = true
if f.FileInfo().IsDir() {
continue
}
if !f.Mode().IsRegular() {
return "", BadAuthRequest("模型包仅允许普通文件")
}
if f.UncompressedSize64 > uint64(live2DMaxFileBytes) || total > live2DMaxExpanded-f.UncompressedSize64 {
return "", BadAuthRequest("模型包解压内容超过限制")
}
total += f.UncompressedSize64
ext := strings.ToLower(path.Ext(name))
if ext != ".json" && ext != ".moc3" && ext != ".png" {
return "", BadAuthRequest("仅支持 JSON、moc3 和 PNG;请移除脚本、音频及其他文件")
}
data, err := readLive2DFile(f, live2DMaxFileBytes)
if err != nil {
return "", err
}
if ext == ".json" && (len(data) > 2<<20 || !json.Valid(data)) {
return "", BadAuthRequest("模型 JSON 无效或超过 2 MiB")
}
if ext == ".moc3" && (len(data) < 4 || string(data[:4]) != "MOC3") {
return "", BadAuthRequest("模型 moc3 文件无效")
}
if ext == ".png" {
cfg, err := png.DecodeConfig(bytes.NewReader(data))
if err != nil || cfg.Width > 4096 || cfg.Height > 4096 {
return "", BadAuthRequest("纹理须为有效 PNG,且不超过 4096×4096")
}
texturePixels += int64(cfg.Width) * int64(cfg.Height)
if texturePixels > 32*1024*1024 {
return "", BadAuthRequest("模型纹理总像素超过 32 Mi,需压缩纹理")
}
}
files[name] = f
if strings.HasSuffix(name, ".model3.json") {
if entry != "" {
return "", BadAuthRequest("一个 ZIP 只能包含一个 model3.json")
}
entry = name
}
}
if entry == "" {
return "", BadAuthRequest("模型包缺少 model3.json 入口")
}
data, err := readLive2DFile(files[entry], 2<<20)
if err != nil {
return "", err
}
var manifest struct {
Version int `json:"Version"`
FileReferences json.RawMessage `json:"FileReferences"`
}
if json.Unmarshal(data, &manifest) != nil || manifest.Version != 3 {
return "", BadAuthRequest("仅支持 model3.json Version 3")
}
var refs live2DReferences
decoder := json.NewDecoder(bytes.NewReader(manifest.FileReferences))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&refs); err != nil {
return "", BadAuthRequest("模型资源引用格式不受支持")
}
if refs.Moc == "" || len(refs.Textures) == 0 || len(refs.Textures) > 8 {
return "", BadAuthRequest("模型须包含 moc3 和 1–8 张纹理")
}
check := func(name, suffix string, required bool) error {
if name == "" && !required {
return nil
}
if !live2DFileName(name) || !strings.HasSuffix(strings.ToLower(name), suffix) || files[path.Join(path.Dir(entry), name)] == nil {
return BadAuthRequest("模型资源缺失、路径非法或类型不符:" + name)
}
return nil
}
if err := check(refs.Moc, ".moc3", true); err != nil {
return "", err
}
for _, name := range refs.Textures {
if err := check(name, ".png", true); err != nil {
return "", err
}
}
for _, name := range []string{refs.Physics, refs.Pose, refs.DisplayInfo, refs.UserData} {
if err := check(name, ".json", false); err != nil {
return "", err
}
}
for _, expression := range refs.Expressions {
if err := check(expression.File, ".exp3.json", true); err != nil {
return "", err
}
}
for _, motions := range refs.Motions {
for _, motion := range motions {
if motion.Sound != "" {
return "", BadAuthRequest("首期不支持模型音频,请移除 Sound 引用")
}
if err := check(motion.File, ".motion3.json", true); err != nil {
return "", err
}
}
}
return entry, nil
}
func (s *Service) UploadLive2D(actor *model.User, header *multipart.FileHeader) (*Live2DImport, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if header == nil {
return nil, BadAuthRequest("请选择 Live2D ZIP")
}
f, err := header.Open()
if err != nil {
return nil, err
}
defer f.Close()
entry, err := validateLive2DArchive(f, header.Size)
if err != nil {
return nil, err
}
header.Header.Set("Content-Type", "application/zip")
resource, err := s.uploadLocalResource(actor.ID, header, "live2d", 0, 0, 0)
if err != nil {
return nil, err
}
return &Live2DImport{ResourceID: resource.ID, Entry: entry}, nil
}
func (s *Service) validateLive2DResource(actor *model.User, id, currentID string) (string, error) {
resource, err := s.repo.Resource(id)
if err != nil || resource.Kind != "live2d" || resource.Provider != "local" || resource.Status != model.ResourceStatusReady {
return "", BadAuthRequest("Live2D 资源不存在或未就绪")
}
if id != currentID && resource.UserID != actor.ID {
return "", Forbidden("只能启用自己上传的模型")
}
_, body, err := s.OpenResource(resource.UserID, id)
if err != nil {
return "", err
}
defer body.Close()
reader, ok := body.(io.ReaderAt)
if !ok {
return "", BadAuthRequest("模型须存储在本地资源目录")
}
return validateLive2DArchive(reader, resource.Size)
}
func (s *Service) Live2DAsset(actor *model.User, id, name string) ([]byte, string, error) {
if !live2DFileName(name) {
return nil, "", BadAuthRequest("模型资源路径无效")
}
_, appearance, err := s.readAppearance()
if err != nil {
return nil, "", err
}
resource, err := s.repo.Resource(id)
if err != nil || resource.Kind != "live2d" || resource.Provider != "local" || resource.Status != model.ResourceStatusReady {
return nil, "", BadAuthRequest("模型资源不可用")
}
if appearance.Canvas.AvatarType != "live2d" || appearance.Canvas.Live2DResourceID != id {
if err := s.RequireAdmin(actor); err != nil {
return nil, "", err
}
if resource.UserID != actor.ID && appearance.Canvas.Live2DResourceID != id {
return nil, "", Forbidden("不能预览其他管理员的未启用模型")
}
}
_, body, err := s.OpenResource(resource.UserID, id)
if err != nil {
return nil, "", err
}
defer body.Close()
reader, ok := body.(io.ReaderAt)
if !ok {
return nil, "", BadAuthRequest("模型资源不可读取")
}
z, err := zip.NewReader(reader, resource.Size)
if err != nil {
return nil, "", err
}
for _, f := range z.File {
if f.Name == name {
mime := "application/octet-stream"
switch strings.ToLower(path.Ext(name)) {
case ".json":
mime = "application/json"
case ".png":
mime = "image/png"
case ".moc3":
default:
return nil, "", BadAuthRequest("不支持的模型资源类型")
}
data, err := readLive2DFile(f, live2DMaxFileBytes)
return data, mime, err
}
}
return nil, "", BadAuthRequest("模型文件不存在")
}