feat(*): 画布助手 - 新增 Live2D 配置导入与模型渲染支持
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
64
backend/internal/app/appearance_canvas.go
Normal file
64
backend/internal/app/appearance_canvas.go
Normal 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
|
||||
}
|
||||
294
backend/internal/app/appearance_live2d.go
Normal file
294
backend/internal/app/appearance_live2d.go
Normal 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("模型文件不存在")
|
||||
}
|
||||
252
backend/internal/app/appearance_live2d_test.go
Normal file
252
backend/internal/app/appearance_live2d_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
72
backend/internal/handler/appearance_live2d.go
Normal file
72
backend/internal/handler/appearance_live2d.go
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user