feat(*): 画布助手 - 新增 Live2D 配置导入与模型渲染支持
This commit is contained in:
1 parent
4f9e79f67f
commit
4e0b0a9256
40 files changed
+1668
-59
No files matched your search
@@ -0,0 +1,252 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func live2DTestFiles(t *testing.T) map[string][]byte {
|
||||
t.Helper()
|
||||
var texture bytes.Buffer
|
||||
if err := png.Encode(&texture, image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return map[string][]byte{
|
||||
"avatar/avatar.model3.json": []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["texture.png"]}}`),
|
||||
"avatar/avatar.moc3": []byte("MOC3-test-validation-only"),
|
||||
"avatar/texture.png": texture.Bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
func live2DTestZIP(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var data bytes.Buffer
|
||||
w := zip.NewWriter(&data)
|
||||
for name, content := range files {
|
||||
f, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data.Bytes()
|
||||
}
|
||||
|
||||
func TestLive2DArchiveAccepts50MiB(t *testing.T) {
|
||||
files := live2DTestFiles(t)
|
||||
// Structural fixture only, not a playable model. Store preserves the ZIP size.
|
||||
moc := make([]byte, 50<<20)
|
||||
copy(moc, "MOC3")
|
||||
files["avatar/avatar.moc3"] = moc
|
||||
var data bytes.Buffer
|
||||
w := zip.NewWriter(&data)
|
||||
for name, content := range files {
|
||||
f, err := w.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data.Len() <= 50<<20 {
|
||||
t.Fatal("fixture must exercise a ZIP larger than 50 MiB")
|
||||
}
|
||||
if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive2DArchiveValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string][]byte)
|
||||
valid bool
|
||||
}{
|
||||
{"valid nested model", func(map[string][]byte) {}, true},
|
||||
{"traversal", func(f map[string][]byte) { f["../escape.json"] = []byte(`{}`) }, false},
|
||||
{"script", func(f map[string][]byte) { f["run.js"] = []byte(`alert(1)`) }, false},
|
||||
{"case collision", func(f map[string][]byte) { f["avatar/TEXTURE.png"] = f["avatar/texture.png"] }, false},
|
||||
{"missing moc", func(f map[string][]byte) { delete(f, "avatar/avatar.moc3") }, false},
|
||||
{"fake moc", func(f map[string][]byte) { f["avatar/avatar.moc3"] = []byte("fake") }, false},
|
||||
{"invalid png", func(f map[string][]byte) { f["avatar/texture.png"] = []byte("not png") }, false},
|
||||
{"external texture", func(f map[string][]byte) {
|
||||
f["avatar/avatar.model3.json"] = []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["https://example.com/texture.png"]}}`)
|
||||
}, false},
|
||||
{"audio reference", func(f map[string][]byte) {
|
||||
f["avatar/avatar.model3.json"] = []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["texture.png"],"Motions":{"Idle":[{"File":"idle.motion3.json","Sound":"voice.wav"}]}}}`)
|
||||
}, false},
|
||||
{"multiple entries", func(f map[string][]byte) { f["other.model3.json"] = f["avatar/avatar.model3.json"] }, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
files := live2DTestFiles(t)
|
||||
tt.mutate(files)
|
||||
data := live2DTestZIP(t, files)
|
||||
entry, err := validateLive2DArchive(bytes.NewReader(data), int64(len(data)))
|
||||
if (err == nil) != tt.valid {
|
||||
t.Fatalf("entry=%q err=%v", entry, err)
|
||||
}
|
||||
if tt.valid && entry != "avatar/avatar.model3.json" {
|
||||
t.Fatal(entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := validateLive2DArchive(bytes.NewReader(nil), Live2DMaxBytes+1); err == nil {
|
||||
t.Fatal("oversized ZIP accepted")
|
||||
}
|
||||
for _, name := range []string{"", "../a", "a/../b", "/a", "a\\b", "a%2fb", "https://host/a", "a?b"} {
|
||||
if live2DFileName(name) {
|
||||
t.Fatalf("unsafe filename accepted: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive2DArchiveRejectsSymlinksAndEntryFlood(t *testing.T) {
|
||||
var data bytes.Buffer
|
||||
w := zip.NewWriter(&data)
|
||||
header := &zip.FileHeader{Name: "link.json", Method: zip.Store}
|
||||
header.SetMode(os.ModeSymlink | 0o777)
|
||||
f, err := w.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write([]byte("outside")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err == nil {
|
||||
t.Fatal("symlink accepted")
|
||||
}
|
||||
data.Reset()
|
||||
w = zip.NewWriter(&data)
|
||||
for i := 0; i < 257; i++ {
|
||||
if _, err := w.Create("duplicate.json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err == nil {
|
||||
t.Fatal("entry flood accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive2DAppearanceImportPublishAndDetach(t *testing.T) {
|
||||
svc, _, _, admin := newAppearanceTestService(t)
|
||||
if _, err := svc.UploadLive2D(nil, nil); err == nil {
|
||||
t.Fatal("anonymous upload accepted")
|
||||
}
|
||||
data := live2DTestZIP(t, live2DTestFiles(t))
|
||||
ordinary, err := svc.UploadResource(admin.ID, multipartFileHeader(t, "ordinary.zip", "application/zip", data), "live2d", 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ordinary.Kind == "live2d" {
|
||||
t.Fatal("ordinary upload claimed the reserved model kind")
|
||||
}
|
||||
imported, err := svc.UploadLive2D(admin, multipartFileHeader(t, "avatar.zip", "application/zip", data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource, err := svc.repo.Resource(imported.ResourceID)
|
||||
if err != nil || resource.Kind != "live2d" || resource.Provider != "local" {
|
||||
t.Fatalf("resource=%+v err=%v", resource, err)
|
||||
}
|
||||
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, imported.Entry); err == nil {
|
||||
t.Fatal("draft is publicly accessible")
|
||||
}
|
||||
if body, mime, err := svc.Live2DAsset(admin, imported.ResourceID, imported.Entry); err != nil || mime != "application/json" || len(body) == 0 {
|
||||
t.Fatalf("preview failed: %s %v", mime, err)
|
||||
}
|
||||
other := &model.User{ID: "other-admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if _, _, err := svc.Live2DAsset(other, imported.ResourceID, imported.Entry); err == nil {
|
||||
t.Fatal("other admin previewed draft")
|
||||
}
|
||||
value := defaultAppearanceSetting()
|
||||
value.Canvas.AgentName = "小鱼"
|
||||
value.Canvas.AvatarType = "live2d"
|
||||
value.Canvas.Live2DResourceID = imported.ResourceID
|
||||
value.Canvas.Live2DEntry = "client-cannot-choose.json"
|
||||
if _, err := svc.UpdateAppearance(other, value); err == nil {
|
||||
t.Fatal("other admin enabled draft")
|
||||
}
|
||||
saved, err := svc.UpdateAppearance(admin, value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Public.Canvas.AgentName != "小鱼" || saved.Public.BrandName != "影策" || saved.Public.Canvas.Live2DEntry != imported.Entry {
|
||||
t.Fatalf("bad public setting: %+v", saved.Public)
|
||||
}
|
||||
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, "avatar/texture.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, "../avatar/texture.png"); err == nil {
|
||||
t.Fatal("traversal accepted")
|
||||
}
|
||||
refs := svc.appearanceResourceReferences([]string{imported.ResourceID})
|
||||
if len(refs[imported.ResourceID]) != 1 {
|
||||
t.Fatalf("missing deletion protection: %+v", refs)
|
||||
}
|
||||
if _, err := svc.AdminResourcePage(admin, AdminResourceQuery{Kind: "live2d", Page: 1, Limit: 20}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{imported.ResourceID}})
|
||||
if err != nil || len(blocked.Blocked) != 1 || len(blocked.Deleted) != 0 {
|
||||
t.Fatalf("referenced model deletion: %+v %v", blocked, err)
|
||||
}
|
||||
value.Canvas.AvatarType = "orb"
|
||||
if _, err := svc.UpdateAppearance(admin, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, imported.Entry); err == nil {
|
||||
t.Fatal("disabled model still public")
|
||||
}
|
||||
if len(svc.appearanceResourceReferences([]string{imported.ResourceID})[imported.ResourceID]) != 1 {
|
||||
t.Fatal("orb selection lost resource reference")
|
||||
}
|
||||
value.Canvas.Live2DResourceID = ""
|
||||
if _, err := svc.UpdateAppearance(admin, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(svc.appearanceResourceReferences([]string{imported.ResourceID})[imported.ResourceID]) != 0 {
|
||||
t.Fatal("detached model still referenced")
|
||||
}
|
||||
deleted, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{imported.ResourceID}})
|
||||
if err != nil || len(deleted.Deleted) != 1 {
|
||||
t.Fatalf("detached model deletion: %+v %v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasAppearanceValidation(t *testing.T) {
|
||||
for _, mutate := range []func(*CanvasAppearance){
|
||||
func(v *CanvasAppearance) { v.AgentName = "\n" },
|
||||
func(v *CanvasAppearance) { v.AgentName = "小\x00鱼" },
|
||||
func(v *CanvasAppearance) { v.AvatarHeight = 1000 },
|
||||
func(v *CanvasAppearance) { v.AvatarType = "invalid" },
|
||||
func(v *CanvasAppearance) { v.AvatarType = "live2d" },
|
||||
} {
|
||||
value := defaultCanvasAppearance()
|
||||
mutate(&value)
|
||||
if _, err := normalizeCanvasAppearance(value); err == nil {
|
||||
t.Fatalf("invalid value accepted: %+v", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user