保留原图并提供单一预览,释放慢传输期间的空间锁 #15
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user