Compare commits

..

3 Commits

Author SHA1 Message Date
codex-bot 8abc79dc5b Keep a single preview variant and verify read lock boundaries
Foundry Go checks / check (push) Successful in 5m13s
Foundry Go checks / check (pull_request) Successful in 3m56s
2026-11-06 21:43:48 +08:00
codex-bot 67765c17f4 Add authenticated image previews and shorten canvas read locks
Foundry Go checks / check (push) Successful in 3m18s
2026-11-06 21:36:07 +08:00
Zayden-Jung 630ed42d53 Merge pull request '补齐真实子画布端口与外部连线原子创建接口' (#14) from codex/canvas-connections into main
Foundry Go checks / check (push) Successful in 6m16s
Deploy Foundry Core / deploy (push) Successful in 5m20s
Reviewed-on: #14
2026-11-06 11:39:53 +00:00
7 changed files with 556 additions and 0 deletions
+24
View File
@@ -604,3 +604,27 @@ api "$B/ai/runs/$(jq -r '.run_id' <<<"$propagated_run")?after_seq=0&limit=100"
The live catalog, rather than the isolated verification IDs above, determines
whether each request can run. A 202 response acknowledges a queued job; use its
terminal status and authenticated canvas/asset rereads as completion evidence.
### Managed image previews
Original asset bytes remain immutable and are used by the ordinary asset URL,
downloads and AI inputs. Authenticated `GET .../assets/{id}?variant=preview`
returns one display rendition bounded to 1024 pixels on its longest edge. Opaque previews use JPEG quality 82, transparency stays PNG, GIF preserves
the original animation, and JPEG EXIF orientation is applied. Small originals
may be returned unchanged when this is more efficient. No preview URL belongs
in node `data.src`: persist the original managed URL and optional original
`assetId`, then select the display variant only in the renderer.
Permissions are checked on every read, including cache hits. Originals are not
replaced. Generation is serialized and the immutable encoded preview cache is
bounded to 32 MiB / 128 entries. Current capabilities advertise `assets.previews`
and `assets.preview_max_edge`. No database migration is needed. Asset reads and
Canvas GET release their permission transaction before encoding/writing the
response. Uploads release the initial workspace lock before receiving a large
multipart body and recheck current write permission before committing.
Legacy inline images must be migrated through an explicit editor action: upload
unchanged bytes with idempotency keys, then save the same node IDs/geometry with
managed asset references and the current canvas version. Opening a canvas does
not migrate it or cause an implicit PATCH.
+242
View File
@@ -0,0 +1,242 @@
package platform
import (
"bytes"
"container/list"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"sync"
"golang.org/x/image/draw"
)
type assetPreviewResult struct {
data []byte
mime string
}
type assetPreviewEntry struct {
key string
value assetPreviewResult
}
var previewMemory = struct {
sync.Mutex
entries map[string]*list.Element
order *list.List
bytes int
}{entries: make(map[string]*list.Element), order: list.New()}
var previewSlots = make(chan struct{}, 1)
func previewWidth(variant, width string) (int, error) {
if variant == "" && width == "" {
return 0, nil
}
if variant == "preview" && (width == "" || width == "1024") {
return 1024, nil
}
return 0, errors.New("invalid_asset_variant")
}
// Cache only immutable bytes, after the caller checks current asset permission.
// Bound both decoded concurrency and retained encoded data.
func cachedAssetPreview(ctx context.Context, encoded []byte, mime string, width int) (assetPreviewResult, error) {
key := fmt.Sprintf("%x:%d", sha256.Sum256(encoded), width)
previewMemory.Lock()
if entry := previewMemory.entries[key]; entry != nil {
previewMemory.order.MoveToFront(entry)
result := entry.Value.(assetPreviewEntry).value
previewMemory.Unlock()
return result, nil
}
previewMemory.Unlock()
select {
case previewSlots <- struct{}{}:
case <-ctx.Done():
return assetPreviewResult{}, ctx.Err()
}
defer func() { <-previewSlots }()
// Another request may have finished this same preview while we waited.
previewMemory.Lock()
if entry := previewMemory.entries[key]; entry != nil {
result := entry.Value.(assetPreviewEntry).value
previewMemory.order.MoveToFront(entry)
previewMemory.Unlock()
return result, nil
}
previewMemory.Unlock()
result, err := makeAssetPreview(encoded, mime, width)
if err != nil {
return result, err
}
if len(result.data) > 8<<20 {
return result, nil
}
previewMemory.Lock()
defer previewMemory.Unlock()
for previewMemory.bytes+len(result.data) > 32<<20 || previewMemory.order.Len() >= 128 {
tail := previewMemory.order.Back()
if tail == nil {
break
}
old := tail.Value.(assetPreviewEntry)
previewMemory.bytes -= len(old.value.data)
delete(previewMemory.entries, old.key)
previewMemory.order.Remove(tail)
}
previewMemory.entries[key] = previewMemory.order.PushFront(assetPreviewEntry{key, result})
previewMemory.bytes += len(result.data)
return result, nil
}
func makeAssetPreview(encoded []byte, mime string, width int) (assetPreviewResult, error) {
original := assetPreviewResult{encoded, mime}
// Keep animation intact. Original bytes remain the canonical AI/download input.
if mime == "image/gif" {
return original, nil
}
config, _, err := image.DecodeConfig(bytes.NewReader(encoded))
if err != nil {
return original, err
}
if config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > assetPixelLimit {
return original, errors.New("invalid_image_dimensions")
}
if config.Width <= width && config.Height <= width && len(encoded) <= 64<<10 {
return original, nil
}
source, _, err := image.Decode(bytes.NewReader(encoded))
if err != nil {
return original, err
}
orientation := jpegOrientation(encoded)
if orientation != 1 {
source = orientedAsset{source, orientation}
}
bounds := source.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w > width || h > width {
if w >= h {
h = max(1, h*width/w)
w = width
} else {
w = max(1, w*width/h)
h = width
}
}
resized := image.NewNRGBA(image.Rect(0, 0, w, h))
draw.CatmullRom.Scale(resized, resized.Bounds(), source, source.Bounds(), draw.Src, nil)
var output bytes.Buffer
outputMime := "image/png"
if resized.Opaque() {
err = jpeg.Encode(&output, resized, &jpeg.Options{Quality: 82})
outputMime = "image/jpeg"
} else {
err = png.Encode(&output, resized)
}
if err != nil {
return original, err
}
if output.Len() >= len(encoded) && orientation == 1 {
return original, nil
}
return assetPreviewResult{output.Bytes(), outputMime}, nil
}
// JPEG EXIF orientation is applied to previews instead of silently rotating a
// phone photograph after its EXIF metadata is removed by the image encoder.
func jpegOrientation(data []byte) int {
if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 {
return 1
}
for at := 2; at+4 <= len(data); {
if data[at] != 0xff {
return 1
}
marker := data[at+1]
if marker == 0xda || marker == 0xd9 {
return 1
}
length := int(binary.BigEndian.Uint16(data[at+2 : at+4]))
if length < 2 || at+2+length > len(data) {
return 1
}
part := data[at+4 : at+2+length]
if marker == 0xe1 && len(part) >= 14 && string(part[:6]) == "Exif\x00\x00" {
tiff := part[6:]
var order binary.ByteOrder
switch string(tiff[:2]) {
case "II":
order = binary.LittleEndian
case "MM":
order = binary.BigEndian
default:
return 1
}
if order.Uint16(tiff[2:4]) != 42 {
return 1
}
offset := int(order.Uint32(tiff[4:8]))
if offset < 8 || offset+2 > len(tiff) {
return 1
}
count := int(order.Uint16(tiff[offset : offset+2]))
for i := 0; i < count; i++ {
entry := offset + 2 + i*12
if entry+12 > len(tiff) {
return 1
}
if order.Uint16(tiff[entry:entry+2]) == 0x112 && order.Uint16(tiff[entry+2:entry+4]) == 3 && order.Uint32(tiff[entry+4:entry+8]) == 1 {
value := int(order.Uint16(tiff[entry+8 : entry+10]))
if value >= 1 && value <= 8 {
return value
}
return 1
}
}
}
at += 2 + length
}
return 1
}
type orientedAsset struct {
image.Image
orientation int
}
func (im orientedAsset) Bounds() image.Rectangle {
b := im.Image.Bounds()
if im.orientation >= 5 {
return image.Rect(0, 0, b.Dy(), b.Dx())
}
return image.Rect(0, 0, b.Dx(), b.Dy())
}
func (im orientedAsset) ColorModel() color.Model { return im.Image.ColorModel() }
func (im orientedAsset) At(x, y int) color.Color {
b := im.Image.Bounds()
w, h := b.Dx(), b.Dy()
switch im.orientation {
case 2:
x = w - 1 - x
case 3:
x, y = w-1-x, h-1-y
case 4:
y = h - 1 - y
case 5:
x, y = y, x
case 6:
x, y = y, h-1-x
case 7:
x, y = w-1-y, h-1-x
case 8:
x, y = w-1-y, x
}
return im.Image.At(b.Min.X+x, b.Min.Y+y)
}
+137
View File
@@ -0,0 +1,137 @@
package platform
import (
"bytes"
"context"
"encoding/binary"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
)
func TestAssetPreviewDownscalesWithoutChangingOriginal(t *testing.T) {
source := image.NewNRGBA(image.Rect(0, 0, 1600, 900))
for y := 0; y < 900; y++ {
for x := 0; x < 1600; x++ {
source.SetNRGBA(x, y, color.NRGBA{uint8(x), uint8(y), uint8(x * y), 255})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, source); err != nil {
t.Fatal(err)
}
original := append([]byte(nil), encoded.Bytes()...)
preview, err := makeAssetPreview(encoded.Bytes(), "image/png", 512)
if err != nil {
t.Fatal(err)
}
config, format, err := image.DecodeConfig(bytes.NewReader(preview.data))
if err != nil || config.Width != 512 || config.Height != 288 || format != "jpeg" {
t.Fatalf("bad preview %s %dx%d %v", format, config.Width, config.Height, err)
}
if len(preview.data) >= len(original) || !bytes.Equal(original, encoded.Bytes()) {
t.Fatal("preview must shrink without changing original")
}
cached, err := cachedAssetPreview(context.Background(), original, "image/png", 512)
if err != nil || !bytes.Equal(cached.data, preview.data) {
t.Fatal("cache result mismatch")
}
}
func TestAssetPreviewPreservesTransparencyAndAnimation(t *testing.T) {
source := image.NewNRGBA(image.Rect(0, 0, 800, 400))
source.SetNRGBA(1, 1, color.NRGBA{255, 0, 0, 255})
var encoded bytes.Buffer
_ = png.Encode(&encoded, source)
preview, err := makeAssetPreview(encoded.Bytes(), "image/png", 256)
if err != nil {
t.Fatal(err)
}
decoded, format, err := image.Decode(bytes.NewReader(preview.data))
if err != nil || format != "png" {
t.Fatal("transparent image must stay PNG")
}
_, _, _, alpha := decoded.At(200, 100).RGBA()
if alpha != 0 {
t.Fatal("transparent background was lost")
}
original := []byte("GIF original animation bytes")
gif, err := makeAssetPreview(original, "image/gif", 512)
if err != nil || !bytes.Equal(gif.data, original) {
t.Fatal("GIF animation changed")
}
}
func TestAssetPreviewRejectsUnboundedWidths(t *testing.T) {
for _, value := range []string{"0", "1", "256", "512", "2048", "999999", "-1", "abc"} {
if _, err := previewWidth("preview", value); err == nil {
t.Errorf("accepted width %s", value)
}
}
for _, value := range []string{"", "1024"} {
if _, err := previewWidth("preview", value); err != nil {
t.Fatal(err)
}
}
if n, err := previewWidth("", ""); err != nil || n != 0 {
t.Fatal("original request changed")
}
if _, err := previewWidth("other", ""); err == nil {
t.Fatal("unknown variant accepted")
}
if _, err := makeAssetPreview([]byte("invalid"), "image/png", 512); err == nil {
t.Fatal("malformed image accepted")
}
}
func TestAssetPreviewAppliesJPEGOrientation(t *testing.T) {
source := image.NewNRGBA(image.Rect(0, 0, 800, 400))
for y := 0; y < 400; y++ {
for x := 0; x < 800; x++ {
source.SetNRGBA(x, y, color.NRGBA{255, 0, 0, 255})
}
}
var original bytes.Buffer
_ = jpeg.Encode(&original, source, nil)
tiff := make([]byte, 26)
copy(tiff, "II")
binary.LittleEndian.PutUint16(tiff[2:], 42)
binary.LittleEndian.PutUint32(tiff[4:], 8)
binary.LittleEndian.PutUint16(tiff[8:], 1)
binary.LittleEndian.PutUint16(tiff[10:], 0x112)
binary.LittleEndian.PutUint16(tiff[12:], 3)
binary.LittleEndian.PutUint32(tiff[14:], 1)
binary.LittleEndian.PutUint16(tiff[18:], 6)
exif := append([]byte("Exif\x00\x00"), tiff...)
segment := append([]byte{0xff, 0xe1, 0, byte(len(exif) + 2)}, exif...)
encoded := append(append(append([]byte{}, original.Bytes()[:2]...), segment...), original.Bytes()[2:]...)
if jpegOrientation(encoded) != 6 {
t.Fatal("EXIF orientation not decoded")
}
preview, err := makeAssetPreview(encoded, "image/jpeg", 256)
if err != nil {
t.Fatal(err)
}
config, _, err := image.DecodeConfig(bytes.NewReader(preview.data))
if err != nil || config.Width != 128 || config.Height != 256 {
t.Fatalf("rotated dimensions %dx%d %v", config.Width, config.Height, err)
}
}
func TestAllAssetOrientationMappings(t *testing.T) {
source := image.NewNRGBA(image.Rect(0, 0, 3, 2))
source.SetNRGBA(0, 0, color.NRGBA{255, 0, 0, 255})
for orientation := 1; orientation <= 8; orientation++ {
oriented := orientedAsset{source, orientation}
red := 0
for y := 0; y < oriented.Bounds().Dy(); y++ {
for x := 0; x < oriented.Bounds().Dx(); x++ {
r, _, _, _ := oriented.At(x, y).RGBA()
if r > 0 {
red++
}
}
}
if red != 1 {
t.Errorf("orientation %d loses/repeats pixels", orientation)
}
}
}
+37
View File
@@ -79,6 +79,10 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
reject(w, 403, "forbidden")
return nil
}
// Do not hold a workspace lock during a slow multipart upload. Recheck below.
if err = tx.Rollback(); err != nil {
return err
}
r.Body = http.MaxBytesReader(w, r.Body, assetLimit+(64<<10))
reader, err := r.MultipartReader()
if err != nil {
@@ -123,6 +127,19 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
reject(w, 400, "invalid_asset")
return nil
}
tx, err = s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err = lockWorkspace(r.Context(), tx, project, workspace, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
digest := sha256.Sum256(encoded)
hash := hex.EncodeToString(digest[:])
info := assetInfo{MIME: mime, Bytes: len(encoded), Width: width, Height: height}
@@ -204,6 +221,11 @@ func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, acco
reject(w, 405, "method_not_allowed")
return nil
}
width, variantErr := previewWidth(r.URL.Query().Get("variant"), r.URL.Query().Get("width"))
if variantErr != nil {
reject(w, 400, "invalid_asset_variant")
return nil
}
id := r.PathValue("asset")
if !uuidSlugPattern.MatchString(id) {
reject(w, 404, "not_found")
@@ -235,6 +257,21 @@ func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, acco
if err != nil {
return err
}
// Permission and immutable bytes are now read consistently. Release locks
// before resizing or waiting for a slow public client to consume the body.
if err = tx.Rollback(); err != nil {
return err
}
variant := "original"
if width > 0 {
preview, e := cachedAssetPreview(r.Context(), encoded, mime, width)
if e != nil {
return e
}
encoded, mime = preview.data, preview.mime
variant = "preview"
}
w.Header().Set("X-Asset-Variant", variant)
w.Header().Set("Content-Type", mime)
w.Header().Set("Content-Length", strconv.Itoa(len(encoded)))
w.Header().Set("X-Content-Type-Options", "nosniff")
+3
View File
@@ -345,6 +345,9 @@ func (s *service) canvas(w http.ResponseWriter, r *http.Request, project, accoun
return nil
}
if r.Method == http.MethodGet {
if err = tx.Rollback(); err != nil {
return err
}
w.Header().Set("X-Canvas-Schema-Version", strconv.Itoa(canvasSchema(document)))
setVersion(w, version)
reply(w, 200, response{"version": version, "document": document})
+4
View File
@@ -24,6 +24,9 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
reject(w, 400, "invalid_material")
return nil
}
if err = tx.Rollback(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
models, modelErr := s.canvasAI.Models(ctx)
@@ -46,6 +49,7 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
"edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true},
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true},
"ai": ai,
"assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true},
"limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000},
"retention": response{"idempotency_min_seconds": 86400, "undo_receipt_seconds": 604800},
})
+109
View File
@@ -12,6 +12,7 @@ import (
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
"image"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -19,7 +20,9 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
@@ -530,6 +533,96 @@ func TestOnboardingAndSharing(t *testing.T) {
t.Fatal("asset bytes changed")
}
}
// Warm the immutable preview cache, then verify it never bypasses permission.
for _, sample := range []struct {
token string
status int
}{{tokens[0], 200}, {tokens[1], 404}, {tokens[2], 404}} {
request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string)+"?variant=preview", nil)
request.Header.Set("Authorization", "Bearer "+sample.token)
result := httptest.NewRecorder()
mux.ServeHTTP(result, request)
if result.Code != sample.status {
t.Fatalf("preview permission got %d want %d", result.Code, sample.status)
}
if result.Code == 200 && (!bytes.Equal(result.Body.Bytes(), assetPNG.Bytes()) || result.Header().Get("Cache-Control") != "private, no-store") {
t.Fatal("preview changed small original or privacy headers")
}
}
// Neither a snapshot nor an asset response may keep the workspace locked
// while a slow downstream consumer is accepting its body.
for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string)} {
var probeErr error
writer := &readLockProbe{ResponseRecorder: httptest.NewRecorder(), probe: func() {
lockTx, e := db.BeginTx(ctx, nil)
if e != nil {
probeErr = e
return
}
defer lockTx.Rollback()
_, probeErr = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace)
}}
request := httptest.NewRequest("GET", path, nil)
request.Header.Set("Authorization", "Bearer "+tokens[0])
request.Header.Set("Accept", accept)
mux.ServeHTTP(writer, request)
if writer.Code != 200 || probeErr != nil {
t.Fatalf("read held workspace lock during response: status=%d error=%v", writer.Code, probeErr)
}
}
// A slow multipart body must not hold the workspace lock either.
func() {
reader, pipe := io.Pipe()
defer reader.Close()
defer pipe.Close()
form := multipart.NewWriter(pipe)
headers := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
var once sync.Once
unblock := func() { once.Do(func() { close(release) }) }
defer unblock()
request := httptest.NewRequest("POST", workspacePath+"/assets", reader)
request.Header.Set("Authorization", "Bearer "+tokens[0])
request.Header.Set("Content-Type", form.FormDataContentType())
request.Header.Set("Idempotency-Key", "99999999-9999-4999-8999-999999999998")
result := httptest.NewRecorder()
go func() { mux.ServeHTTP(result, request); close(done) }()
go func() {
part, e := form.CreateFormFile("image", "slow.png")
close(headers)
if e != nil {
return
}
<-release
_, _ = part.Write(assetPNG.Bytes())
_ = form.Close()
_ = pipe.Close()
}()
select {
case <-headers:
case <-time.After(5 * time.Second):
t.Fatal("upload did not consume headers")
}
lockTx, e := db.BeginTx(ctx, nil)
if e != nil {
t.Fatal(e)
}
_, e = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace)
_ = lockTx.Rollback()
unblock()
if e != nil {
t.Fatalf("upload held workspace lock while receiving body: %v", e)
}
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("upload did not finish")
}
if result.Code != 201 {
t.Fatalf("slow upload failed: %d", result.Code)
}
}()
download(tokens[0], 200)
download(tokens[1], 404)
download(tokens[2], 404)
@@ -668,3 +761,19 @@ func TestOnboardingAndSharing(t *testing.T) {
t.Fatalf("click accounting incorrect %v", err)
}
}
// A real SQL NOWAIT probe at the response-write boundary catches locks that
// would otherwise remain invisible in a buffered httptest recorder.
type readLockProbe struct {
*httptest.ResponseRecorder
probe func()
}
func (w *readLockProbe) Write(body []byte) (int, error) {
if w.probe != nil {
probe := w.probe
w.probe = nil
probe()
}
return w.ResponseRecorder.Write(body)
}