From 3d2f01554da54edd6916ff2a2218dfcf805776f6 Mon Sep 17 00:00:00 2001 From: ila Date: Fri, 21 Aug 2026 00:53:27 +0800 Subject: [PATCH] fix: align WebP uploads with confirmed prototype (#11) --- docs/02-architecture-and-code-map.md | 6 +++--- internal/platform/storage/local.go | 3 +++ internal/platform/storage/local_test.go | 18 +++++++++++++++++- portal/main.go | 2 +- portal/service/service.go | 5 +++-- portal/service/validation_test.go | 13 +++++++++++++ 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 7793c20..86b770c 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.- -wiki_revision: f9f5d67d06d86694948f5812ce8a565e30b4f266 -synchronized_at: 2026-08-20T16:41:50Z +wiki_revision: 04391bbce2fcc59da2078265c020a8a845ca4c16 +synchronized_at: 2026-08-20T16:52:38Z # 架构与代码地图 @@ -101,7 +101,7 @@ MVP-0 第一张上传图自动作为 `primary`,其余为 `reference`;不提 - `portal/session` 使用服务端内存会话和 HMAC 不透明 Cookie;登录成功与退出都会轮换 session ID 和 CSRF token。Cookie 为 HttpOnly/SameSite=Lax,生产启用 Secure;写请求只接受 `X-CSRF-Token`,避免 multipart 在正文限流前被隐式解析。 - MVP-0 会话仅支持单 portal 进程,重启后统一失效;会话数量有内存上限并在创建时清理过期项。多实例共享会话不在 MVP-0 范围。 - `internal/platform/password` 固定 `bcrypt:v1:` 版本化编码;未知版本失败关闭。登录对未知账号、错误密码和禁用账号执行同类密码校验并返回同一错误,按远端地址做内存窗口节流。 -- `portal/service` 统一处理 prompt 渲染、用户作用域幂等、上传数量/单文件/总量、声明与实际 MIME、扩展名、解码和像素校验;第一张图为 primary,其余为 reference。 +- `portal/service` 统一处理 prompt 渲染、用户作用域幂等、上传数量/单文件/总量、JPG/PNG/WebP 的声明与实际 MIME、扩展名、解码和像素校验;第一张图为 primary,其余为 reference。 - `CreateIdempotentPrepared` 在事务插入 generation 后才执行 inputs 回调,因而文件 metadata 可使用真实 generation ID;幂等重放不执行回调,回调或数据库失败只补偿删除本次保存文件。 - 同步提交只创建 pending、inputs 和 rendered_prompt,不 import 或调用 Provider/worker,也不读取点数。JSON 与 HTMX 查询共享同一用户作用域服务,认证过期返回 401;HTMX 额外返回登录跳转提示,不修改 generation 状态。 - 任务、原图、生成图和缩略图查询先用 generation.user_id 过滤,再核对文件 metadata 的 owner/generation;响应只给受控 URL、内容和安全文件名,不暴露 storage key 或文件系统路径。 diff --git a/internal/platform/storage/local.go b/internal/platform/storage/local.go index 4b4f810..41f213f 100644 --- a/internal/platform/storage/local.go +++ b/internal/platform/storage/local.go @@ -15,6 +15,7 @@ import ( corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" "github.com/disintegration/imaging" + _ "golang.org/x/image/webp" ) var ( @@ -381,6 +382,8 @@ func imageMIME(format string) string { return "image/png" case "gif": return "image/gif" + case "webp": + return "image/webp" default: return "" } diff --git a/internal/platform/storage/local_test.go b/internal/platform/storage/local_test.go index 84dcdcb..4fd94d3 100644 --- a/internal/platform/storage/local_test.go +++ b/internal/platform/storage/local_test.go @@ -3,6 +3,7 @@ package storage import ( "bytes" "context" + "encoding/base64" "errors" "image" "image/color" @@ -19,7 +20,7 @@ func newTestStore(t *testing.T, maxBytes int64, maxPixels uint64) *Local { t.Helper() store, err := NewLocal(Config{ Root: t.TempDir(), MaxObjectBytes: maxBytes, MaxImagePixels: maxPixels, ThumbnailMaxSide: 256, - AllowedImageMIME: map[string]bool{"image/png": true, "image/jpeg": true}, + AllowedImageMIME: map[string]bool{"image/png": true, "image/jpeg": true, "image/webp": true}, }) if err != nil { t.Fatal(err) @@ -27,6 +28,21 @@ func newTestStore(t *testing.T, maxBytes int64, maxPixels uint64) *Local { return store } +func TestPutWebPCreatesPNGThumbnail(t *testing.T) { + data, err := base64.StdEncoding.DecodeString("UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA") + if err != nil { + t.Fatal(err) + } + store := newTestStore(t, 1024, 100) + objects, err := store.PutImage(context.Background(), ImageRequest{Key: "webp/original", ThumbnailKey: "webp/thumbnail", OwnerID: 1, GenerationID: 1, ContentType: "image/webp", Source: bytes.NewReader(data)}) + if err != nil { + t.Fatal(err) + } + if objects.Original.ContentType != "image/webp" || objects.Thumbnail.ContentType != "image/png" { + t.Fatalf("objects=%#v", objects) + } +} + func TestLocalPutOpenAtomicMetadataAndTraversal(t *testing.T) { store := newTestStore(t, 1024, 1_000_000) object, err := store.Put(context.Background(), corestorage.PutRequest{ diff --git a/portal/main.go b/portal/main.go index 8135e94..a60e7d2 100644 --- a/portal/main.go +++ b/portal/main.go @@ -51,7 +51,7 @@ func run() error { return errors.New("configure portal database") } defer sqlDB.Close() - storage, err := platformstorage.NewLocal(platformstorage.Config{Root: cfg.StorageRoot, MaxObjectBytes: cfg.MaxImageBytes, MaxImagePixels: cfg.MaxImagePixels, ThumbnailMaxSide: 256, AllowedImageMIME: map[string]bool{"image/png": true, "image/jpeg": true}}) + storage, err := platformstorage.NewLocal(platformstorage.Config{Root: cfg.StorageRoot, MaxObjectBytes: cfg.MaxImageBytes, MaxImagePixels: cfg.MaxImagePixels, ThumbnailMaxSide: 256, AllowedImageMIME: map[string]bool{"image/png": true, "image/jpeg": true, "image/webp": true}}) if err != nil { return err } diff --git a/portal/service/service.go b/portal/service/service.go index 86f8bc2..b6a5513 100644 --- a/portal/service/service.go +++ b/portal/service/service.go @@ -16,6 +16,7 @@ import ( "git.ilapage.cn/OPC/chorus/internal/core/model" "git.ilapage.cn/OPC/chorus/internal/core/queue" corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" + _ "golang.org/x/image/webp" "gorm.io/gorm" ) @@ -153,11 +154,11 @@ func (s *Service) validateImages(uploads []Upload) ([]validatedImage, error) { detected = detected[:separator] } declared := strings.TrimSpace(strings.Split(upload.DeclaredMIME, ";")[0]) - if (detected != "image/png" && detected != "image/jpeg") || declared != detected { + if (detected != "image/png" && detected != "image/jpeg" && detected != "image/webp") || declared != detected { return nil, &FileError{index, name, ErrImageMIME} } extension := strings.ToLower(path.Ext(name)) - if (detected == "image/png" && extension != ".png") || (detected == "image/jpeg" && extension != ".jpg" && extension != ".jpeg") { + if (detected == "image/png" && extension != ".png") || (detected == "image/jpeg" && extension != ".jpg" && extension != ".jpeg") || (detected == "image/webp" && extension != ".webp") { return nil, &FileError{index, name, ErrImageExtension} } config, _, err := image.DecodeConfig(bytes.NewReader(upload.Content)) diff --git a/portal/service/validation_test.go b/portal/service/validation_test.go index e859ae5..de248c8 100644 --- a/portal/service/validation_test.go +++ b/portal/service/validation_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "encoding/base64" "errors" "image" "image/color" @@ -53,6 +54,18 @@ func TestImageCountAndTotalSize(t *testing.T) { } } +func TestWebPValidationMatchesConfirmedPrototype(t *testing.T) { + data, err := base64.StdEncoding.DecodeString("UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA") + if err != nil { + t.Fatal(err) + } + service := &Service{config: Config{MaxImages: 1, MaxImageBytes: 1000, MaxUploadBytes: 1000, MaxImagePixels: 100}} + images, err := service.validateImages([]Upload{{Name: "pixel.webp", DeclaredMIME: "image/webp", Content: data}}) + if err != nil || len(images) != 1 || images[0].MIME != "image/webp" { + t.Fatalf("webp validation=%#v error=%v", images, err) + } +} + func smallPNG(width, height int) []byte { img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := 0; y < height; y++ {