295 lines
9.2 KiB
Go
295 lines
9.2 KiB
Go
package app
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/json"
|
||
"image/png"
|
||
"io"
|
||
"mime/multipart"
|
||
"path"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"infinite-canvas/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("模型文件不存在")
|
||
}
|