feat(*): 画布助手 - 新增 Live2D 配置导入与模型渲染支持

This commit is contained in:
ddCat
2026-09-21 14:11:29 +08:00
parent b8eefdad70
commit c5b81f696c
40 changed files with 1668 additions and 59 deletions

View File

@@ -138,7 +138,7 @@ func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminReso
Limit: limit,
Offset: (page - 1) * limit,
}
if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file") {
if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file", "live2d") {
return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源类型筛选无效")
}
if filter.Status != "" && !oneOf(filter.Status, string(model.ResourceStatusPending), string(model.ResourceStatusReady), string(model.ResourceStatusFailed), string(model.ResourceStatusDeleted)) {

View File

@@ -31,7 +31,7 @@ const (
)
const (
appearanceSchemaVersion = 8
appearanceSchemaVersion = 9
appearanceLogoMaxBytes int64 = 5 << 20
appearancePosterMaxBytes int64 = 10 << 20
appearanceVideoMaxBytes int64 = 256 << 20
@@ -48,6 +48,7 @@ const (
)
type AppearanceSetting struct {
Canvas CanvasAppearance `json:"canvas"`
SchemaVersion int `json:"schemaVersion"`
BrandName string `json:"brandName"`
BrandSlug string `json:"brandSlug"`
@@ -70,6 +71,7 @@ type AppearanceSetting struct {
}
type PublicAppearanceSetting struct {
Canvas CanvasAppearance `json:"canvas"`
SchemaVersion int `json:"schemaVersion"`
BrandName string `json:"brandName"`
BrandSlug string `json:"brandSlug"`
@@ -109,6 +111,7 @@ type AdminAppearanceSetting struct {
func defaultAppearanceSetting() AppearanceSetting {
return AppearanceSetting{
Canvas: defaultCanvasAppearance(),
SchemaVersion: appearanceSchemaVersion,
BrandName: defaultAppearanceBrandName,
BrandSlug: defaultAppearanceBrandSlug,
@@ -169,6 +172,11 @@ func (s *Service) UpdateAppearance(actor *model.User, value AppearanceSetting) (
return nil, err
}
value.SchemaVersion = appearanceSchemaVersion
canvas, canvasErr := normalizeCanvasAppearance(value.Canvas)
if canvasErr != nil {
return nil, canvasErr
}
value.Canvas = canvas
value.BrandName = strings.TrimSpace(value.BrandName)
value.BrandSlug = strings.ToLower(strings.TrimSpace(value.BrandSlug))
value.AuthHeroTitle = normalizeAppearanceCopy(value.AuthHeroTitle)
@@ -197,6 +205,13 @@ func (s *Service) UpdateAppearance(actor *model.User, value AppearanceSetting) (
if err != nil {
return nil, err
}
if value.Canvas.Live2DResourceID != "" {
entry, err := s.validateLive2DResource(actor, value.Canvas.Live2DResourceID, before.Canvas.Live2DResourceID)
if err != nil {
return nil, err
}
value.Canvas.Live2DEntry = entry
}
for _, candidate := range []struct {
slot string
resourceID string
@@ -321,6 +336,7 @@ func (s *Service) appearanceResourceReferences(resourceIDs []string) map[string]
{resourceID: value.DarkLogoResourceID, title: "深色模式品牌 Logo"},
{resourceID: value.AuthVideoResourceID, title: "登录页品牌视频"},
{resourceID: value.AuthVideoPosterResourceID, title: "登录页视频封面"},
{resourceID: value.Canvas.Live2DResourceID, title: "画布 Agent Live2D 形象"},
}
wanted := make(map[string]struct{}, len(resourceIDs))
for _, resourceID := range resourceIDs {
@@ -615,6 +631,7 @@ func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetti
revision = strconv.FormatInt(setting.UpdatedAt.UTC().UnixNano(), 36)
}
result := &PublicAppearanceSetting{
Canvas: value.Canvas,
SchemaVersion: appearanceSchemaVersion,
BrandName: value.BrandName,
BrandSlug: value.BrandSlug,

View File

@@ -0,0 +1,64 @@
package app
import (
"strings"
"unicode"
)
// CanvasAppearance is presentation-only; no free-form system prompts or tool permissions.
type CanvasAppearance struct {
AgentName string `json:"agentName"`
LauncherLabel string `json:"launcherLabel"`
PanelTitle string `json:"panelTitle"`
WelcomeTitle string `json:"welcomeTitle"`
WelcomeDescription string `json:"welcomeDescription"`
InputPlaceholder string `json:"inputPlaceholder"`
AvatarType string `json:"avatarType"`
Live2DResourceID string `json:"live2dResourceId"`
Live2DEntry string `json:"live2dEntry"`
AvatarHeight int `json:"avatarHeight"`
}
func defaultCanvasAppearance() CanvasAppearance {
return CanvasAppearance{AgentName: "影策", LauncherLabel: "Agent", PanelTitle: "画布助手", WelcomeTitle: "在这里,和{agentName}让灵感,慢慢成形", WelcomeDescription: "从一个想法开始,和{agentName}一起创作。", InputPlaceholder: "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills", AvatarType: "orb", AvatarHeight: 220}
}
func normalizeCanvasAppearance(value CanvasAppearance) (CanvasAppearance, error) {
// Older API clients have no canvas object; keep their default presentation valid.
if value == (CanvasAppearance{}) {
return defaultCanvasAppearance(), nil
}
for _, field := range []struct {
value *string
label string
max int
required bool
}{
{&value.AgentName, "助手名称", 24, true}, {&value.LauncherLabel, "入口文字", 24, false},
{&value.PanelTitle, "面板标题", 40, true}, {&value.WelcomeTitle, "欢迎标题", 100, true},
{&value.WelcomeDescription, "欢迎说明", 200, false}, {&value.InputPlaceholder, "输入提示", 160, true},
} {
*field.value = strings.TrimSpace(*field.value)
if err := validateAppearanceCopy(*field.value, field.label, field.max, field.required); err != nil {
return value, err
}
}
for _, ch := range value.AgentName {
if unicode.IsControl(ch) {
return value, BadAuthRequest("助手名称不能包含控制字符")
}
}
if value.AvatarType != "orb" && value.AvatarType != "live2d" {
return value, BadAuthRequest("请选择有效的 Agent 形象类型")
}
if value.AvatarHeight < 120 || value.AvatarHeight > 360 {
return value, BadAuthRequest("形象高度须在 120–360 之间")
}
if value.AvatarType == "live2d" && value.Live2DResourceID == "" {
return value, BadAuthRequest("请先导入 Live2D 模型")
}
if value.Live2DResourceID == "" {
value.Live2DEntry = ""
}
return value, nil
}

View File

@@ -0,0 +1,294 @@
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("模型文件不存在")
}

View File

@@ -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)
}
}
}

View File

@@ -500,7 +500,10 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim
return nil, false, resourceUploadInProgress()
}
now := time.Now()
kind = normalizeResourceKind(kind, mimeType)
// Live2D is reserved for the administrator's validated local import path.
if !(forceLocal && kind == "live2d") {
kind = normalizeResourceKind(kind, mimeType)
}
var setting ossSettingValue
var storageSettingID string
var useOSS bool

View File

@@ -11,6 +11,7 @@ import (
)
func RegisterAppearanceRoutes(r *gin.RouterGroup, svc *service.Service) {
registerLive2DRoutes(r, svc)
r.GET("/public/appearance", func(c *gin.Context) {
setting, err := svc.Appearance()
if err != nil {

View File

@@ -0,0 +1,72 @@
package handler
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/service"
)
func registerLive2DRoutes(r *gin.RouterGroup, svc *service.Service) {
r.POST("/admin/settings/appearance/live2d", func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.RequireAdmin(actor); err != nil {
failService(c, err)
return
}
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "admin-live2d-upload:"+actor.ID, policy.Request.ResourceUploadPerMinute, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.Live2DMaxBytes+(1<<20))
file, err := c.FormFile("file")
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.UploadLive2D(actor, file)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"model": result})
})
serve := func(admin bool) gin.HandlerFunc {
return func(c *gin.Context) {
var actor *model.User
if admin {
var err error
actor, err = currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.RequireAdmin(actor); err != nil {
failService(c, err)
return
}
}
data, mime, err := svc.Live2DAsset(actor, c.Param("id"), strings.TrimPrefix(c.Param("file"), "/"))
if err != nil {
failService(c, err)
return
}
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "private, no-cache")
c.Header("Content-Security-Policy", "default-src 'none'")
c.Data(http.StatusOK, mime, data)
}
}
r.GET("/public/appearance/live2d/:id/*file", serve(false))
r.GET("/admin/settings/appearance/live2d/:id/*file", serve(true))
}

View File

@@ -13,12 +13,15 @@ func TestAppearanceRoutesAreRegistered(t *testing.T) {
router := gin.New()
RegisterAppearanceRoutes(router.Group("/api"), &service.Service{})
wanted := map[string]bool{
"GET /api/public/appearance": false,
"GET /api/public/appearance/assets/:slot": false,
"GET /api/admin/settings/appearance": false,
"PATCH /api/admin/settings/appearance": false,
"DELETE /api/admin/settings/appearance": false,
"POST /api/admin/settings/appearance/assets/:slot": false,
"POST /api/admin/settings/appearance/live2d": false,
"GET /api/admin/settings/appearance/live2d/:id/*file": false,
"GET /api/public/appearance/live2d/:id/*file": false,
"GET /api/public/appearance": false,
"GET /api/public/appearance/assets/:slot": false,
"GET /api/admin/settings/appearance": false,
"PATCH /api/admin/settings/appearance": false,
"DELETE /api/admin/settings/appearance": false,
"POST /api/admin/settings/appearance/assets/:slot": false,
}
for _, route := range router.Routes() {
key := route.Method + " " + route.Path

View File

@@ -26,6 +26,68 @@ tags:
- name: agent
- name: admin
paths:
/admin/settings/appearance/live2d:
post:
tags: [admin]
summary: 导入管理员 Live2D 模型草稿(不自动启用)
description: ZIP 最大 128 MiB,最多 256 条目;解压累计 384 MiB,单文件最大 128 MiB。只接受一个 Version 3 model3.json 及 ZIP 内 MOC3、PNG、JSON 引用。启用通过 PATCH /admin/settings/appearance 的 canvas 配置完成。
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
"200":
description: 成功信封 data.model 包含 resourceId 和服务端确认的 entry
content:
application/json:
schema:
type: object
properties:
code: {type: integer, enum: [0]}
msg: {type: string}
data:
type: object
properties:
model:
type: object
required: [resourceId, entry]
properties:
resourceId: {type: string}
entry: {type: string}
"400": {$ref: "#/components/responses/Error"}
"401": {$ref: "#/components/responses/Error"}
"403": {$ref: "#/components/responses/Error"}
"429": {$ref: "#/components/responses/Error"}
/admin/settings/appearance/live2d/{id}/{file}:
get:
tags: [admin]
summary: 读取模型草稿文件(管理员所有者或当前配置引用)
parameters:
- {in: path, name: id, required: true, schema: {type: string}}
- {in: path, name: file, required: true, description: ZIP 内相对路径,可包含多级目录, schema: {type: string}}
responses:
"200": {description: JSON、PNG 或 MOC3 文件,非 JSON 信封}
"400": {$ref: "#/components/responses/Error"}
"401": {$ref: "#/components/responses/Error"}
"403": {$ref: "#/components/responses/Error"}
/public/appearance/live2d/{id}/{file}:
get:
tags: [meta]
security: []
summary: 读取当前启用的 Live2D 模型文件
parameters:
- {in: path, name: id, required: true, schema: {type: string}}
- {in: path, name: file, required: true, description: ZIP 内相对路径,可包含多级目录, schema: {type: string}}
responses:
"200": {description: JSON、PNG 或 MOC3 文件,非 JSON 信封;停用后拒绝公开读取}
"400": {$ref: "#/components/responses/Error"}
"401": {$ref: "#/components/responses/Error"}
"403": {$ref: "#/components/responses/Error"}
/tools:
get:
tags: [canvas]

View File

@@ -3,6 +3,7 @@ package service
import "infinite-canvas/backend/internal/app"
const (
Live2DMaxBytes = app.Live2DMaxBytes
AnnouncementImageMaxBytes = app.AnnouncementImageMaxBytes
AppearanceAssetDarkLogo = app.AppearanceAssetDarkLogo
AppearanceAssetLogo = app.AppearanceAssetLogo