Detect a fetched image's format from its bytes once (closes #319)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
magic.DetectFormat returns an imagerequest.Format. ValidateMagicBytes returns the format it checked against the upstream Content-Type. The cache reads a cached source's format from its file's first bytes, as it reads a variant's. The proxy hands the format to Process. Process refuses a format that is not a source format, and input libvips would load with another loader, such as an SVG passing as AVIF. Format.MIMEType declares each MIME string once, and the fetcher, the cache's variant read and the Accept matching use it. magic.MIMEType, magic.ImageFormat, MIMEToImageFormat, magic's SVG detection, detectFormat, formatFromString, FormatToMIME and Result.InputFormat are gone. Model: opus-5-5
This commit was merged in pull request #336.
This commit is contained in:
@@ -191,10 +191,11 @@ allowlisted source hosts for open access.
|
|||||||
name.
|
name.
|
||||||
|
|
||||||
Each source image and each transformed image has one row in the database,
|
Each source image and each transformed image has one row in the database,
|
||||||
holding its size and when it was last used. Two source URLs that serve the same
|
holding its size and when it was last used. pixa reads a cached source image's
|
||||||
bytes are stored twice, once under each URL. A source image is stored by writing
|
format from its file's first bytes. When they match none of the source formats,
|
||||||
its file and then its row; when the row cannot be written, the file is removed
|
pixa fetches the source again. Two source URLs that serve the same bytes are
|
||||||
again.
|
stored twice, once under each URL. A source image is stored by writing its file
|
||||||
|
and then its row; when the row cannot be written, the file is removed again.
|
||||||
|
|
||||||
Source and transformed image files are written under a temporary name, synced to
|
Source and transformed image files are written under a temporary name, synced to
|
||||||
disk, and only then renamed into place, so a crash leaves no file under its own
|
disk, and only then renamed into place, so a crash leaves no file under its own
|
||||||
|
|||||||
@@ -30,6 +30,30 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-26 a fetched image's format is detected from its bytes once, as an
|
||||||
|
`imagerequest.Format` (closes #319). The upstream `Content-Type` check in the
|
||||||
|
fetcher stays as the filter before downloading.
|
||||||
|
- `magic.DetectFormat` returns an `imagerequest.Format`, and
|
||||||
|
`magic.ValidateMagicBytes` returns the format it checked against the
|
||||||
|
`Content-Type`.
|
||||||
|
- `Format.MIMEType` gives each format's MIME type. The fetcher, the mock
|
||||||
|
fetcher, the cache's read of a variant's type and the handler's `Accept`
|
||||||
|
matching use it.
|
||||||
|
- `Cache.Source` returns a source's format, read from its file's first bytes
|
||||||
|
as `Cache.Variant` reads a variant's. A file in no source format is an
|
||||||
|
error, and the proxy fetches the source again.
|
||||||
|
- The proxy hands the format to `ImageProcessor.Process`. The processor
|
||||||
|
refuses input that libvips would load as another format, as before, with
|
||||||
|
`ErrUnsupportedInputFormat`. It refuses a format that is not one of the
|
||||||
|
six source formats the same way.
|
||||||
|
- `internal/magic` no longer detects SVG, which pixa does not read. SVG data
|
||||||
|
is `ErrUnknownFormat`.
|
||||||
|
- Removed:
|
||||||
|
- `magic.MIMEType`, `magic.ImageFormat` and `magic.MIMEToImageFormat`
|
||||||
|
- `detectFormat`, `formatFromString`, `FormatToMIME` and
|
||||||
|
`Result.InputFormat` of the processor
|
||||||
|
- the fetcher's MIME constants of the six formats
|
||||||
|
- `errNotAnOutputFormat` of the cache
|
||||||
- 2026-10-26 the cache keeps its source images in one `imgcache.FileStore` and
|
- 2026-10-26 the cache keeps its source images in one `imgcache.FileStore` and
|
||||||
its variants in another (closes #318). A `FileStore` is a directory of files,
|
its variants in another (closes #318). A `FileStore` is a directory of files,
|
||||||
each named by a hex key, at `<ab>/<cd>/<key>`.
|
each named by a hex key, at `<ab>/<cd>/<key>`.
|
||||||
|
|||||||
@@ -65,20 +65,20 @@ func formatForAccept(accept string) (imagerequest.Format, error) {
|
|||||||
return imagerequest.FormatJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/jxl"] > 0 {
|
if qualities[imagerequest.FormatJXL.MIMEType()] > 0 {
|
||||||
return imagerequest.FormatJXL, nil
|
return imagerequest.FormatJXL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/avif"] > 0 {
|
if qualities[imagerequest.FormatAVIF.MIMEType()] > 0 {
|
||||||
return imagerequest.FormatAVIF, nil
|
return imagerequest.FormatAVIF, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if qualities["image/webp"] > 0 {
|
if qualities[imagerequest.FormatWebP.MIMEType()] > 0 {
|
||||||
return imagerequest.FormatWebP, nil
|
return imagerequest.FormatWebP, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For JPEG, the most specific entry the header has decides
|
// For JPEG, the most specific entry the header has decides
|
||||||
quality, named := qualities["image/jpeg"]
|
quality, named := qualities[imagerequest.FormatJPEG.MIMEType()]
|
||||||
if !named {
|
if !named {
|
||||||
quality, named = qualities["image/*"]
|
quality, named = qualities["image/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
|
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
|
||||||
// answers the errors of both image routes, answers 415 with a clear error when
|
// answers the errors of both image routes, answers 415 with a clear error when
|
||||||
// the upstream answers with a content type pixa does not accept, such as SVG,
|
// the upstream answers with a content type pixa does not accept, such as SVG,
|
||||||
// and when orig is asked of a source in a format the processor cannot name,
|
// and when libvips does not read a source as the format its first bytes
|
||||||
// wrapped as the service wraps them.
|
// show, wrapped as the service wraps them.
|
||||||
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
|||||||
for _, err := range []error{
|
for _, err := range []error{
|
||||||
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
|
||||||
httpfetcher.ErrInvalidContentType),
|
httpfetcher.ErrInvalidContentType),
|
||||||
fmt.Errorf("image processing failed: %w: unknown",
|
fmt.Errorf("image processing failed: %w: libvips does not read the input as avif",
|
||||||
imageprocessor.ErrUnsupportedInputFormat),
|
imageprocessor.ErrUnsupportedInputFormat),
|
||||||
} {
|
} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func startUpstream(t *testing.T) *httptest.Server {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
_, _ = io.WriteString(w, imagePayload)
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -358,7 +358,7 @@ func TestFetchContentLengthBodyAtMaxResponseSize(t *testing.T) {
|
|||||||
|
|
||||||
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(tc.size))
|
w.Header().Set("Content-Length", strconv.Itoa(tc.size))
|
||||||
_, _ = io.WriteString(w, body)
|
_, _ = io.WriteString(w, body)
|
||||||
})
|
})
|
||||||
@@ -406,7 +406,7 @@ func TestFetchChunkedBodyAtMaxResponseSize(t *testing.T) {
|
|||||||
|
|
||||||
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
res := fetchWithMaxResponseSize(t, maxResponseSize,
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
_, _ = io.WriteString(w, body)
|
_, _ = io.WriteString(w, body)
|
||||||
// With no Content-Length, flushing sends the body
|
// With no Content-Length, flushing sends the body
|
||||||
// chunked; its end is sent when the handler returns.
|
// chunked; its end is sent when the handler returns.
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ const userAgent = "pixa/1.0"
|
|||||||
// MaxConnections fetches are already in progress.
|
// MaxConnections fetches are already in progress.
|
||||||
const ConnectionWaitTimeout = 10 * time.Second
|
const ConnectionWaitTimeout = 10 * time.Second
|
||||||
|
|
||||||
// MIME content types.
|
|
||||||
const (
|
|
||||||
contentTypeJPEG = "image/jpeg"
|
|
||||||
contentTypePNG = "image/png"
|
|
||||||
contentTypeGIF = "image/gif"
|
|
||||||
contentTypeWebP = "image/webp"
|
|
||||||
contentTypeAVIF = "image/avif"
|
|
||||||
contentTypeJXL = "image/jxl"
|
|
||||||
contentTypeSVG = "image/svg+xml"
|
|
||||||
contentTypeOctetStream = "application/octet-stream"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Loopback addresses blocked by SSRF protection.
|
// Loopback addresses blocked by SSRF protection.
|
||||||
const (
|
const (
|
||||||
localhostIPv4 = "127.0.0.1"
|
localhostIPv4 = "127.0.0.1"
|
||||||
@@ -121,15 +109,16 @@ type FetchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// allowedContentTypes returns the content types an upstream response may
|
// allowedContentTypes returns the content types an upstream response may
|
||||||
// have, which Fetch also sends as its Accept header.
|
// have, which Fetch also sends as its Accept header: the MIME types of the
|
||||||
|
// formats pixa reads.
|
||||||
func allowedContentTypes() []string {
|
func allowedContentTypes() []string {
|
||||||
return []string{
|
return []string{
|
||||||
contentTypeJPEG,
|
imagerequest.FormatJPEG.MIMEType(),
|
||||||
contentTypePNG,
|
imagerequest.FormatPNG.MIMEType(),
|
||||||
contentTypeGIF,
|
imagerequest.FormatGIF.MIMEType(),
|
||||||
contentTypeWebP,
|
imagerequest.FormatWebP.MIMEType(),
|
||||||
contentTypeAVIF,
|
imagerequest.FormatAVIF.MIMEType(),
|
||||||
contentTypeJXL,
|
imagerequest.FormatJXL.MIMEType(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ func TestIsAllowedContentType(t *testing.T) {
|
|||||||
contentType string
|
contentType string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{contentTypeJPEG, true},
|
{imagerequest.FormatJPEG.MIMEType(), true},
|
||||||
{contentTypePNG, true},
|
{imagerequest.FormatPNG.MIMEType(), true},
|
||||||
{contentTypeWebP, true},
|
{imagerequest.FormatWebP.MIMEType(), true},
|
||||||
{"image/jpeg; charset=utf-8", true},
|
{"image/jpeg; charset=utf-8", true},
|
||||||
{"IMAGE/JPEG", true},
|
{"IMAGE/JPEG", true},
|
||||||
{"text/html", false},
|
{"text/html", false},
|
||||||
@@ -217,7 +217,7 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = result.Content.Close() }()
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
if result.ContentType != contentTypeJPEG {
|
if result.ContentType != imagerequest.FormatJPEG.MIMEType() {
|
||||||
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,13 +272,13 @@ func TestDetectContentTypeFromPath(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"foo/bar.jpg", contentTypeJPEG},
|
{"foo/bar.jpg", imagerequest.FormatJPEG.MIMEType()},
|
||||||
{"foo/bar.JPG", contentTypeJPEG},
|
{"foo/bar.JPG", imagerequest.FormatJPEG.MIMEType()},
|
||||||
{"foo/bar.jpeg", contentTypeJPEG},
|
{"foo/bar.jpeg", imagerequest.FormatJPEG.MIMEType()},
|
||||||
{"foo/bar.png", contentTypePNG},
|
{"foo/bar.png", imagerequest.FormatPNG.MIMEType()},
|
||||||
{"foo/bar.gif", contentTypeGIF},
|
{"foo/bar.gif", imagerequest.FormatGIF.MIMEType()},
|
||||||
{"foo/bar.webp", contentTypeWebP},
|
{"foo/bar.webp", imagerequest.FormatWebP.MIMEType()},
|
||||||
{"foo/bar.avif", contentTypeAVIF},
|
{"foo/bar.avif", imagerequest.FormatAVIF.MIMEType()},
|
||||||
{"foo/bar.svg", contentTypeSVG},
|
{"foo/bar.svg", contentTypeSVG},
|
||||||
{"foo/bar.bin", contentTypeOctetStream},
|
{"foo/bar.bin", contentTypeOctetStream},
|
||||||
{"foo/bar", contentTypeOctetStream},
|
{"foo/bar", contentTypeOctetStream},
|
||||||
|
|||||||
@@ -66,23 +66,29 @@ func (m *MockFetcher) Fetch(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The content types the mock fetcher gives files in no format pixa reads.
|
||||||
|
const (
|
||||||
|
contentTypeSVG = "image/svg+xml"
|
||||||
|
contentTypeOctetStream = "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
// detectContentTypeFromPath returns the MIME type based on file extension.
|
// detectContentTypeFromPath returns the MIME type based on file extension.
|
||||||
func detectContentTypeFromPath(path string) string {
|
func detectContentTypeFromPath(path string) string {
|
||||||
path = strings.ToLower(path)
|
path = strings.ToLower(path)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
||||||
return contentTypeJPEG
|
return imagerequest.FormatJPEG.MIMEType()
|
||||||
case strings.HasSuffix(path, ".png"):
|
case strings.HasSuffix(path, ".png"):
|
||||||
return contentTypePNG
|
return imagerequest.FormatPNG.MIMEType()
|
||||||
case strings.HasSuffix(path, ".gif"):
|
case strings.HasSuffix(path, ".gif"):
|
||||||
return contentTypeGIF
|
return imagerequest.FormatGIF.MIMEType()
|
||||||
case strings.HasSuffix(path, ".webp"):
|
case strings.HasSuffix(path, ".webp"):
|
||||||
return contentTypeWebP
|
return imagerequest.FormatWebP.MIMEType()
|
||||||
case strings.HasSuffix(path, ".avif"):
|
case strings.HasSuffix(path, ".avif"):
|
||||||
return contentTypeAVIF
|
return imagerequest.FormatAVIF.MIMEType()
|
||||||
case strings.HasSuffix(path, ".jxl"):
|
case strings.HasSuffix(path, ".jxl"):
|
||||||
return contentTypeJXL
|
return imagerequest.FormatJXL.MIMEType()
|
||||||
case strings.HasSuffix(path, ".svg"):
|
case strings.HasSuffix(path, ".svg"):
|
||||||
return contentTypeSVG
|
return contentTypeSVG
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFetchSendsRequestID verifies that a fetch sends the ID of the request
|
// TestFetchSendsRequestID verifies that a fetch sends the ID of the request
|
||||||
@@ -25,7 +26,7 @@ func TestFetchSendsRequestID(t *testing.T) {
|
|||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
received <- r.Header.Get("X-Request-Id")
|
received <- r.Header.Get("X-Request-Id")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
w.Header().Set("Content-Type", imagerequest.FormatJPEG.MIMEType())
|
||||||
_, _ = io.WriteString(w, imagePayload)
|
_, _ = io.WriteString(w, imagePayload)
|
||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|||||||
@@ -39,30 +39,31 @@ func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input []byte
|
input []byte
|
||||||
|
sourceFormat imagerequest.Format
|
||||||
format imagerequest.Format
|
format imagerequest.Format
|
||||||
width, height int
|
width, height int
|
||||||
fit imagerequest.Fit
|
fit imagerequest.Fit
|
||||||
refused bool
|
refused bool
|
||||||
}{
|
}{
|
||||||
{"AVIF at the limit", cutOff, imagerequest.FormatAVIF,
|
{"AVIF at the limit", cutOff, imagerequest.FormatPNG,
|
||||||
4096, 4096, imagerequest.FitCover, false},
|
imagerequest.FormatAVIF, 4096, 4096, imagerequest.FitCover, false},
|
||||||
{"AVIF above the limit", cutOff, imagerequest.FormatAVIF,
|
{"AVIF above the limit", cutOff, imagerequest.FormatPNG,
|
||||||
4096, 4097, imagerequest.FitCover, true},
|
imagerequest.FormatAVIF, 4096, 4097, imagerequest.FitCover, true},
|
||||||
{"WebP above the AVIF limit", cutOff, imagerequest.FormatWebP,
|
{"WebP above the AVIF limit", cutOff, imagerequest.FormatPNG,
|
||||||
4096, 4097, imagerequest.FitCover, false},
|
imagerequest.FormatWebP, 4096, 4097, imagerequest.FitCover, false},
|
||||||
// FitInside does not enlarge, so the output stays 64x32
|
// FitInside does not enlarge, so the output stays 64x32
|
||||||
{"AVIF inside a larger size", cutOff, imagerequest.FormatAVIF,
|
{"AVIF inside a larger size", cutOff, imagerequest.FormatPNG,
|
||||||
8192, 8192, imagerequest.FitInside, false},
|
imagerequest.FormatAVIF, 8192, 8192, imagerequest.FitInside, false},
|
||||||
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
|
// FitOutside scales 64x32 by 128 to cover 8192x1: 8192x4096
|
||||||
{"AVIF outside a smaller size", cutOff, imagerequest.FormatAVIF,
|
{"AVIF outside a smaller size", cutOff, imagerequest.FormatPNG,
|
||||||
8192, 1, imagerequest.FitOutside, true},
|
imagerequest.FormatAVIF, 8192, 1, imagerequest.FitOutside, true},
|
||||||
// With no size, the output keeps the source's 4097x4096
|
// With no size, the output keeps the source's 4097x4096
|
||||||
{"AVIF of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
{"AVIF of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
||||||
0, 0, imagerequest.FitCover, true},
|
imagerequest.FormatAVIF, 0, 0, imagerequest.FitCover, true},
|
||||||
{"orig of an AVIF source", largeAVIF, imagerequest.FormatOriginal,
|
{"orig of an AVIF source", largeAVIF, imagerequest.FormatAVIF,
|
||||||
0, 0, imagerequest.FitCover, true},
|
imagerequest.FormatOriginal, 0, 0, imagerequest.FitCover, true},
|
||||||
{"JPEG of the source's size", largeAVIF, imagerequest.FormatJPEG,
|
{"JPEG of the source's size", largeAVIF, imagerequest.FormatAVIF,
|
||||||
0, 0, imagerequest.FitCover, false},
|
imagerequest.FormatJPEG, 0, 0, imagerequest.FitCover, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -72,7 +73,7 @@ func TestImageProcessor_RefusesAVIFAboveMaxPixels(t *testing.T) {
|
|||||||
_, err := New(Params{
|
_, err := New(Params{
|
||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(), bytes.NewReader(tt.input),
|
}).Process(t.Context(), bytes.NewReader(tt.input), tt.sourceFormat,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
|
Size: imagerequest.Size{Width: tt.width, Height: tt.height},
|
||||||
Format: tt.format,
|
Format: tt.format,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
@@ -111,7 +111,7 @@ func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// readerAfterDeadline is a Process input whose first Read waits until done
|
// readerAfterDeadline is a Process input whose first Read waits until done
|
||||||
@@ -86,7 +88,8 @@ func checkProcessStopsAtDeadline(
|
|||||||
|
|
||||||
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
result, err := proc.Process(ctx, input, smallJPEGTransform())
|
result, err := proc.Process(ctx, input, imagerequest.FormatJPEG,
|
||||||
|
smallJPEGTransform())
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Logf("Process() made a %dx%d image after its deadline passed",
|
t.Logf("Process() made a %dx%d image after its deadline passed",
|
||||||
result.Width, result.Height)
|
result.Width, result.Height)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestImageProcessor_EmptyFormatRefused(t *testing.T) {
|
|||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(
|
}).Process(
|
||||||
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
|
t.Context(), bytes.NewReader(createTestJPEG(t, 20, 20)),
|
||||||
&imagerequest.Transform{},
|
imagerequest.FormatJPEG, &imagerequest.Transform{},
|
||||||
)
|
)
|
||||||
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
if !errors.Is(err, ErrUnsupportedOutputFormat) {
|
||||||
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
t.Errorf("Process() error = %v, want %v", err, ErrUnsupportedOutputFormat)
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// processedSize runs input through Process and returns the output's size in
|
// processedSize runs input, an image in sourceFormat, through Process and
|
||||||
// bytes.
|
// returns the output's size in bytes.
|
||||||
func processedSize(
|
func processedSize(
|
||||||
t *testing.T, input []byte, transform *imagerequest.Transform,
|
t *testing.T, input []byte, sourceFormat imagerequest.Format,
|
||||||
|
transform *imagerequest.Transform,
|
||||||
) int64 {
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, err := New(Params{
|
result, err := New(Params{
|
||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(), bytes.NewReader(input), transform)
|
}).Process(t.Context(), bytes.NewReader(input), sourceFormat, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@ func TestImageProcessor_PNGIsCompressed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
size := processedSize(t, encodePNG(t, flat),
|
size := processedSize(t, encodePNG(t, flat), imagerequest.FormatPNG,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
&imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||||
|
|
||||||
const rawBytes = width * height * 3
|
const rawBytes = width * height * 3
|
||||||
@@ -92,7 +93,7 @@ func TestImageProcessor_WebPAtEffort2(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
size := processedSize(t, input,
|
size := processedSize(t, input, imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
|
&imagerequest.Transform{Format: imagerequest.FormatWebP, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
want, _, err := decode(t, input).ExportWebp(&vips.WebpExportParams{
|
||||||
@@ -134,7 +135,7 @@ func TestImageProcessor_AVIFAtEffort1(t *testing.T) {
|
|||||||
|
|
||||||
input := encodePNG(t, source)
|
input := encodePNG(t, source)
|
||||||
|
|
||||||
size := processedSize(t, input,
|
size := processedSize(t, input, imagerequest.FormatPNG,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
|
&imagerequest.Transform{Format: imagerequest.FormatAVIF, Quality: 85})
|
||||||
|
|
||||||
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
want, _, err := decode(t, input).ExportAvif(&vips.AvifExportParams{
|
||||||
@@ -161,7 +162,7 @@ func TestImageProcessor_JPEGXLAtEffort4(t *testing.T) {
|
|||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
size := processedSize(t, input,
|
size := processedSize(t, input, imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: 85})
|
||||||
|
|
||||||
params := vips.NewJxlExportParams()
|
params := vips.NewJxlExportParams()
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestImageProcessor_FitCoverServesThinSource(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
@@ -113,7 +113,7 @@ func TestImageProcessor_FitCoverCropsTheCentre(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatPNG,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||||
Format: imagerequest.FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
@@ -240,7 +240,7 @@ func TestImageProcessor_FitCoverKeepsProportions(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatPNG,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatPNG,
|
Format: imagerequest.FormatPNG,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
@@ -102,7 +102,7 @@ func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{
|
&imagerequest.Transform{
|
||||||
Size: tt.size,
|
Size: tt.size,
|
||||||
Format: imagerequest.FormatJPEG,
|
Format: imagerequest.FormatJPEG,
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ type Result struct {
|
|||||||
InputWidth int
|
InputWidth int
|
||||||
// InputHeight is the original image height before processing.
|
// InputHeight is the original image height before processing.
|
||||||
InputHeight int
|
InputHeight int
|
||||||
// InputFormat is the detected input format (e.g., "jpeg", "png").
|
|
||||||
InputFormat string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxInputDimension is the maximum allowed width or height for input images.
|
// MaxInputDimension is the maximum allowed width or height for input images.
|
||||||
@@ -113,9 +111,10 @@ var ErrAVIFOutputTooLarge = errors.New("AVIF output has too many pixels")
|
|||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
||||||
// libvips reads the input's first bytes as another format than the source
|
// libvips reads the input's first bytes as another format than its source
|
||||||
// formats, or as none: for example an SVG whose first bytes pass the
|
// format, or as none. An SVG whose first bytes pass the first-bytes check for
|
||||||
// first-bytes check for AVIF.
|
// AVIF is one example. A source format other than the six pixa reads is also
|
||||||
|
// refused with it.
|
||||||
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||||
|
|
||||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
@@ -157,9 +156,10 @@ func New(params Params) *ImageProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to transform. When
|
// Process transforms the image read from input according to transform.
|
||||||
// MaxConcurrentProcessing images are already being processed, it waits up
|
// sourceFormat is the input's format, which magic detected from its first
|
||||||
// to ProcessingWaitTimeout for one to finish, then fails with
|
// bytes. When MaxConcurrentProcessing images are already being processed,
|
||||||
|
// it waits up to ProcessingWaitTimeout for one to finish, then fails with
|
||||||
// ErrTooManyImages. When ctx has ended once Process has a slot, or before it
|
// ErrTooManyImages. When ctx has ended once Process has a slot, or before it
|
||||||
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
|
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
|
||||||
// no way to stop libvips once an encode has begun, so an encode keeps its
|
// no way to stop libvips once an encode has begun, so an encode keeps its
|
||||||
@@ -168,6 +168,7 @@ func New(params Params) *ImageProcessor {
|
|||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
|
sourceFormat imagerequest.Format,
|
||||||
transform *imagerequest.Transform,
|
transform *imagerequest.Transform,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
release, err := p.acquireSlot(ctx)
|
release, err := p.acquireSlot(ctx)
|
||||||
@@ -182,13 +183,9 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// govips picks the libvips loader from the input's first bytes with
|
// Input libvips would not load as sourceFormat is refused before it is
|
||||||
// DetermineImageType. Input whose first bytes it reads as no source
|
// decoded
|
||||||
// format, such as SVG, which librsvg would render, is refused before
|
err = checkInputFormat(data, sourceFormat)
|
||||||
// libvips reads it.
|
|
||||||
inputFormat := p.detectFormat(vips.DetermineImageType(data))
|
|
||||||
|
|
||||||
sourceFormat, err := p.formatFromString(inputFormat)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -254,12 +251,11 @@ func (p *ImageProcessor) Process(
|
|||||||
return &Result{
|
return &Result{
|
||||||
Content: io.NopCloser(bytes.NewReader(output)),
|
Content: io.NopCloser(bytes.NewReader(output)),
|
||||||
ContentLength: int64(len(output)),
|
ContentLength: int64(len(output)),
|
||||||
ContentType: FormatToMIME(outputFormat),
|
ContentType: outputFormat.MIMEType(),
|
||||||
Width: img.Width(),
|
Width: img.Width(),
|
||||||
Height: img.Height(),
|
Height: img.Height(),
|
||||||
InputWidth: origWidth,
|
InputWidth: origWidth,
|
||||||
InputHeight: origHeight,
|
InputHeight: origHeight,
|
||||||
InputFormat: inputFormat,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,38 +290,6 @@ func targetDimensions(
|
|||||||
return width, height, nil
|
return width, height, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIME types for the supported image formats.
|
|
||||||
const (
|
|
||||||
mimeJPEG = "image/jpeg"
|
|
||||||
mimePNG = "image/png"
|
|
||||||
mimeGIF = "image/gif"
|
|
||||||
mimeWebP = "image/webp"
|
|
||||||
mimeAVIF = "image/avif"
|
|
||||||
mimeJXL = "image/jxl"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FormatToMIME converts a Format to its MIME type string.
|
|
||||||
func FormatToMIME(format imagerequest.Format) string {
|
|
||||||
switch format {
|
|
||||||
case imagerequest.FormatJPEG:
|
|
||||||
return mimeJPEG
|
|
||||||
case imagerequest.FormatPNG:
|
|
||||||
return mimePNG
|
|
||||||
case imagerequest.FormatWebP:
|
|
||||||
return mimeWebP
|
|
||||||
case imagerequest.FormatGIF:
|
|
||||||
return mimeGIF
|
|
||||||
case imagerequest.FormatAVIF:
|
|
||||||
return mimeAVIF
|
|
||||||
case imagerequest.FormatJXL:
|
|
||||||
return mimeJXL
|
|
||||||
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
|
||||||
return "application/octet-stream"
|
|
||||||
default:
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WaitForProcessing waits until no image is being processed, or until ctx
|
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||||
// ends, and returns how many images were still being processed then. It
|
// ends, and returns how many images were still being processed then. It
|
||||||
// waits by taking each slot in processingSemaphore as it frees up until it
|
// waits by taking each slot in processingSemaphore as it frees up until it
|
||||||
@@ -394,27 +358,49 @@ func (p *ImageProcessor) readInput(input io.Reader) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string for a govips image type.
|
// checkInputFormat returns ErrUnsupportedInputFormat unless libvips would
|
||||||
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
// load data with the loader of sourceFormat. govips picks the libvips loader
|
||||||
switch imageType {
|
// from the input's first bytes with DetermineImageType. It refuses an SVG
|
||||||
case vips.ImageTypeJPEG:
|
// whose first bytes pass the first-bytes check for AVIF, which librsvg would
|
||||||
return "jpeg"
|
// otherwise render.
|
||||||
case vips.ImageTypePNG:
|
func checkInputFormat(data []byte, sourceFormat imagerequest.Format) error {
|
||||||
return "png"
|
// A format with no loader, such as orig or auto, has the type govips also
|
||||||
case vips.ImageTypeGIF:
|
// gives input it cannot read, which the check below would let through.
|
||||||
return "gif"
|
sourceType := vipsImageType(sourceFormat)
|
||||||
case vips.ImageTypeWEBP:
|
if sourceType == vips.ImageTypeUnknown {
|
||||||
return "webp"
|
return fmt.Errorf("%w: %q is not a source format",
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
ErrUnsupportedInputFormat, sourceFormat)
|
||||||
return string(imagerequest.FormatAVIF)
|
}
|
||||||
case vips.ImageTypeJXL:
|
|
||||||
return string(imagerequest.FormatJXL)
|
if vips.DetermineImageType(data) != sourceType {
|
||||||
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
return fmt.Errorf("%w: libvips does not read the input as %s",
|
||||||
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
ErrUnsupportedInputFormat, sourceFormat)
|
||||||
vips.ImageTypeJP2K:
|
}
|
||||||
return "unknown"
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// vipsImageType returns the govips image type whose libvips loader reads
|
||||||
|
// format. orig and auto stand for another format, so they have
|
||||||
|
// vips.ImageTypeUnknown, which govips has no loader for.
|
||||||
|
func vipsImageType(format imagerequest.Format) vips.ImageType {
|
||||||
|
switch format {
|
||||||
|
case imagerequest.FormatJPEG:
|
||||||
|
return vips.ImageTypeJPEG
|
||||||
|
case imagerequest.FormatPNG:
|
||||||
|
return vips.ImageTypePNG
|
||||||
|
case imagerequest.FormatGIF:
|
||||||
|
return vips.ImageTypeGIF
|
||||||
|
case imagerequest.FormatWebP:
|
||||||
|
return vips.ImageTypeWEBP
|
||||||
|
case imagerequest.FormatAVIF:
|
||||||
|
return vips.ImageTypeAVIF
|
||||||
|
case imagerequest.FormatJXL:
|
||||||
|
return vips.ImageTypeJXL
|
||||||
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
|
return vips.ImageTypeUnknown
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return vips.ImageTypeUnknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,26 +754,3 @@ func jxlDistance(quality int) float64 {
|
|||||||
|
|
||||||
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
|
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatFromString converts a format string from detectFormat to Format. A
|
|
||||||
// format detectFormat could not name is ErrUnsupportedInputFormat.
|
|
||||||
func (p *ImageProcessor) formatFromString(
|
|
||||||
format string,
|
|
||||||
) (imagerequest.Format, error) {
|
|
||||||
switch format {
|
|
||||||
case "jpeg":
|
|
||||||
return imagerequest.FormatJPEG, nil
|
|
||||||
case "png":
|
|
||||||
return imagerequest.FormatPNG, nil
|
|
||||||
case "gif":
|
|
||||||
return imagerequest.FormatGIF, nil
|
|
||||||
case "webp":
|
|
||||||
return imagerequest.FormatWebP, nil
|
|
||||||
case string(imagerequest.FormatAVIF):
|
|
||||||
return imagerequest.FormatAVIF, nil
|
|
||||||
case string(imagerequest.FormatJXL):
|
|
||||||
return imagerequest.FormatJXL, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -94,23 +94,23 @@ func isAVIF(data []byte) bool {
|
|||||||
// detectMIME is a minimal magic-byte detector for test assertions.
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
func detectMIME(data []byte) string {
|
func detectMIME(data []byte) string {
|
||||||
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||||
return mimeJPEG
|
return imagerequest.FormatJPEG.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||||||
return mimePNG
|
return imagerequest.FormatPNG.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||||
return mimeGIF
|
return imagerequest.FormatGIF.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||||
return mimeWebP
|
return imagerequest.FormatWebP.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if isAVIF(data) {
|
if isAVIF(data) {
|
||||||
return mimeAVIF
|
return imagerequest.FormatAVIF.MIMEType()
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
@@ -131,7 +131,8 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != mimeJPEG {
|
if mime != imagerequest.FormatJPEG.MIMEType() {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +177,8 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -189,7 +191,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != mimePNG {
|
if mime != imagerequest.FormatPNG.MIMEType() {
|
||||||
t.Errorf("Output format = %v, want image/png", mime)
|
t.Errorf("Output format = %v, want image/png", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +215,8 @@ func processAndCheckSize(
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -253,7 +256,8 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitContain,
|
FitMode: imagerequest.FitContain,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -296,7 +300,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatPNG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -341,7 +346,8 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
_, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Process() should reject oversized input images")
|
t.Error("Process() should reject oversized input images")
|
||||||
}
|
}
|
||||||
@@ -369,7 +375,8 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"Process() should accept images at MaxInputDimension, got error: %v",
|
"Process() should accept images at MaxInputDimension, got error: %v",
|
||||||
@@ -399,7 +406,8 @@ func encodeAndCheck(
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -430,7 +438,7 @@ func encodeAndCheck(
|
|||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, imagerequest.FormatWebP, 80, mimeWebP)
|
encodeAndCheck(t, imagerequest.FormatWebP, 80, imagerequest.FormatWebP.MIMEType())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
@@ -453,7 +461,8 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatAVIF, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
|
||||||
}
|
}
|
||||||
@@ -467,7 +476,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mime := detectMIME(data)
|
mime := detectMIME(data)
|
||||||
if mime != mimeJPEG {
|
if mime != imagerequest.FormatJPEG.MIMEType() {
|
||||||
t.Errorf("Output format = %v, want image/jpeg", mime)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,7 +503,8 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
_, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
}
|
}
|
||||||
@@ -521,7 +531,8 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
FitMode: imagerequest.FitCover,
|
FitMode: imagerequest.FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(ctx, bytes.NewReader(input), transform)
|
result, err := proc.Process(ctx, bytes.NewReader(input),
|
||||||
|
imagerequest.FormatJPEG, transform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v, want nil", err)
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -532,11 +543,11 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
|||||||
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encodeAndCheck(t, imagerequest.FormatAVIF, 85, mimeAVIF)
|
encodeAndCheck(t, imagerequest.FormatAVIF, 85, imagerequest.FormatAVIF.MIMEType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndDecode runs input through Process and decodes the output with
|
// processAndDecode runs input, a JPEG, through Process and decodes the output
|
||||||
// vips, so a test can inspect the image a client would receive.
|
// with vips, so a test can inspect the image a client would receive.
|
||||||
func processAndDecode(
|
func processAndDecode(
|
||||||
t *testing.T, input []byte, transform *imagerequest.Transform,
|
t *testing.T, input []byte, transform *imagerequest.Transform,
|
||||||
) *vips.ImageRef {
|
) *vips.ImageRef {
|
||||||
@@ -546,7 +557,8 @@ func processAndDecode(
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(
|
}).Process(
|
||||||
context.Background(), bytes.NewReader(input), transform,
|
context.Background(), bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
|
transform,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ func TestImageProcessor_EncodeJPEGXL(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(
|
}).Process(
|
||||||
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)), transform,
|
t.Context(), bytes.NewReader(createTestJPEG(t, 200, 150)),
|
||||||
|
imagerequest.FormatJPEG, transform,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
@@ -71,7 +72,7 @@ func TestImageProcessor_JPEGXLQuality(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(t.Context(),
|
}).Process(t.Context(),
|
||||||
bytes.NewReader(input),
|
bytes.NewReader(input), imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
|
&imagerequest.Transform{Format: imagerequest.FormatJXL, Quality: quality})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
t.Fatalf("Process() at quality %d error = %v", quality, err)
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ func processInBackground(
|
|||||||
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
proc *ImageProcessor, reader *gatedReader, results chan<- error,
|
||||||
) {
|
) {
|
||||||
go func() {
|
go func() {
|
||||||
result, err := proc.Process(context.Background(), reader, smallJPEGTransform())
|
result, err := proc.Process(context.Background(), reader,
|
||||||
|
imagerequest.FormatJPEG, smallJPEGTransform())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = result.Content.Close()
|
_ = result.Content.Close()
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ func TestProcessWaitsThenFailsWhenNoSlotFrees(t *testing.T) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
_, err := proc.Process(context.Background(), bytes.NewReader(input),
|
||||||
smallJPEGTransform())
|
imagerequest.FormatJPEG, smallJPEGTransform())
|
||||||
if !errors.Is(err, ErrTooManyImages) {
|
if !errors.Is(err, ErrTooManyImages) {
|
||||||
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
t.Fatalf("Process() error = %v, want ErrTooManyImages", err)
|
||||||
}
|
}
|
||||||
@@ -274,7 +275,8 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
proc := New(Params{MaxInputBytes: 4096, MaxConcurrentProcessing: 1})
|
||||||
proc.processingWaitTimeout = 100 * time.Millisecond
|
proc.processingWaitTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
_, err := proc.Process(context.Background(), tc.input, tc.transform)
|
_, err := proc.Process(context.Background(), tc.input,
|
||||||
|
imagerequest.FormatJPEG, tc.transform)
|
||||||
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
if err == nil || (tc.want != nil && !errors.Is(err, tc.want)) {
|
||||||
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
t.Fatalf("Process() error = %v, want %v", err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -284,7 +286,7 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
result, err := proc.Process(context.Background(), bytes.NewReader(valid),
|
||||||
smallJPEGTransform())
|
imagerequest.FormatJPEG, smallJPEGTransform())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() after the error = %v, want nil", err)
|
t.Fatalf("Process() after the error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
||||||
// input, which is not a source format, is refused with
|
// input is refused with ErrUnsupportedInputFormat before it is decoded, and
|
||||||
// ErrUnsupportedInputFormat before it is decoded, and not saved as JPEG.
|
// not saved as JPEG. TIFF is not a source format, and the test passes the
|
||||||
|
// input to Process as a JPEG.
|
||||||
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
|||||||
MaxInputBytes: 50 << 20,
|
MaxInputBytes: 50 << 20,
|
||||||
MaxConcurrentProcessing: 1,
|
MaxConcurrentProcessing: 1,
|
||||||
}).Process(
|
}).Process(
|
||||||
t.Context(), bytes.NewReader(tiff),
|
t.Context(), bytes.NewReader(tiff), imagerequest.FormatJPEG,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
|
&imagerequest.Transform{Format: imagerequest.FormatOriginal},
|
||||||
)
|
)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package imageprocessor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
|||||||
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
} {
|
} {
|
||||||
saved, err := proc.Process(t.Context(),
|
saved, err := proc.Process(t.Context(),
|
||||||
bytes.NewReader(createTestPNG(t, 20, 20)),
|
bytes.NewReader(createTestPNG(t, 20, 20)), imagerequest.FormatPNG,
|
||||||
&imagerequest.Transform{Format: format})
|
&imagerequest.Transform{Format: format})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("saving the test image as %s: %v", format, err)
|
t.Fatalf("saving the test image as %s: %v", format, err)
|
||||||
@@ -32,7 +33,7 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
|||||||
t.Fatalf("reading the %s image: %v", format, err)
|
t.Fatalf("reading the %s image: %v", format, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := proc.Process(t.Context(), bytes.NewReader(source),
|
result, err := proc.Process(t.Context(), bytes.NewReader(source), format,
|
||||||
&imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
&imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("orig of the %s image: %v", format, err)
|
t.Errorf("orig of the %s image: %v", format, err)
|
||||||
@@ -42,9 +43,33 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("orig of the %s image: %s", format, result.ContentType)
|
t.Logf("orig of the %s image: %s", format, result.ContentType)
|
||||||
|
|
||||||
if result.ContentType != FormatToMIME(format) {
|
if result.ContentType != format.MIMEType() {
|
||||||
t.Errorf("orig of the %s image is %s, want %s",
|
t.Errorf("orig of the %s image is %s, want %s",
|
||||||
format, result.ContentType, FormatToMIME(format))
|
format, result.ContentType, format.MIMEType())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageProcessor_NotASourceFormatRefused verifies that a source format
|
||||||
|
// other than the six, such as orig, auto or none, is refused with
|
||||||
|
// ErrUnsupportedInputFormat. The input is text, which libvips reads as no
|
||||||
|
// format.
|
||||||
|
func TestImageProcessor_NotASourceFormatRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
|
||||||
|
|
||||||
|
for _, sourceFormat := range []imagerequest.Format{
|
||||||
|
imagerequest.FormatOriginal, imagerequest.FormatAuto, "",
|
||||||
|
} {
|
||||||
|
result, err := proc.Process(t.Context(),
|
||||||
|
bytes.NewReader([]byte("this is text, not an image")), sourceFormat,
|
||||||
|
&imagerequest.Transform{Format: imagerequest.FormatJPEG})
|
||||||
|
t.Logf("source format %q: error = %v", sourceFormat, err)
|
||||||
|
|
||||||
|
if result != nil || !errors.Is(err, ErrUnsupportedInputFormat) {
|
||||||
|
t.Errorf("Process() with source format %q = %v, error %v; want %v",
|
||||||
|
sourceFormat, result, err, ErrUnsupportedInputFormat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,21 +303,22 @@ func withoutCancelKeepingDeadline(
|
|||||||
return context.WithDeadline(context.WithoutCancel(ctx), deadline)
|
return context.WithDeadline(context.WithoutCancel(ctx), deadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCachedSource opens src's cached source, without reading it, and
|
// loadCachedSource opens src's cached source, reading only the first bytes,
|
||||||
// returns it with its size; nil if it is not in the cache, cannot be read,
|
// which give its format, and returns it with its size and its format. It
|
||||||
// is empty or exceeds maxResponseSize.
|
// returns nil if the source is not in the cache, cannot be read, is in no
|
||||||
|
// source format or exceeds maxResponseSize.
|
||||||
func (p *Proxy) loadCachedSource(
|
func (p *Proxy) loadCachedSource(
|
||||||
ctx context.Context, src imagerequest.Source,
|
ctx context.Context, src imagerequest.Source,
|
||||||
) (io.ReadCloser, int64) {
|
) (io.ReadCloser, int64, imagerequest.Format) {
|
||||||
reader, size, err := p.cache.Source(ctx, src)
|
reader, size, format, err := p.cache.Source(ctx, src)
|
||||||
if errors.Is(err, imgcache.ErrNotFound) {
|
if errors.Is(err, imgcache.ErrNotFound) {
|
||||||
return nil, 0
|
return nil, 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Warn("failed to load cached source, fetching", "error", err)
|
p.log.Warn("failed to load cached source, fetching", "error", err)
|
||||||
|
|
||||||
return nil, 0
|
return nil, 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
p.log.Debug("using cached source", "source_key", src.Key())
|
p.log.Debug("using cached source", "source_key", src.Key())
|
||||||
@@ -330,16 +331,10 @@ func (p *Proxy) loadCachedSource(
|
|||||||
"max_bytes", p.maxResponseSize,
|
"max_bytes", p.maxResponseSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0
|
return nil, 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if size == 0 {
|
return reader, size, format
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return reader, size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFromSourceOrFetch processes an image, using cached source content
|
// processFromSourceOrFetch processes an image, using cached source content
|
||||||
@@ -349,7 +344,7 @@ func (p *Proxy) processFromSourceOrFetch(
|
|||||||
req *imagerequest.Request,
|
req *imagerequest.Request,
|
||||||
cacheKey imagerequest.VariantKey,
|
cacheKey imagerequest.VariantKey,
|
||||||
) (*processedVariant, error) {
|
) (*processedVariant, error) {
|
||||||
source, sourceSize := p.loadCachedSource(ctx, req.Source)
|
source, sourceSize, sourceFormat := p.loadCachedSource(ctx, req.Source)
|
||||||
|
|
||||||
// Fetch from upstream if we don't have source data or it's empty
|
// Fetch from upstream if we don't have source data or it's empty
|
||||||
if source == nil {
|
if source == nil {
|
||||||
@@ -361,7 +356,7 @@ func (p *Proxy) processFromSourceOrFetch(
|
|||||||
// Process using cached source; nothing was fetched from upstream. The
|
// Process using cached source; nothing was fetched from upstream. The
|
||||||
// image processor reads the source only once it has a processing slot,
|
// image processor reads the source only once it has a processing slot,
|
||||||
// so a request waiting for one holds none of it in memory.
|
// so a request waiting for one holds none of it in memory.
|
||||||
return p.processAndStore(ctx, req, cacheKey, source, sourceSize)
|
return p.processAndStore(ctx, req, cacheKey, source, sourceFormat, sourceSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
||||||
@@ -442,10 +437,11 @@ func (p *Proxy) fetchAndProcess(
|
|||||||
"cipher", fetchResult.TLSCipherSuite,
|
"cipher", fetchResult.TLSCipherSuite,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate magic bytes match content type. A body that does not is the
|
// The source's format, detected from its first bytes, which must match
|
||||||
// upstream's error, so it gets a negative cache entry, as an upstream
|
// its content type. A body that does not is the upstream's error, so it
|
||||||
// error status does.
|
// gets a negative cache entry, as an upstream error status does.
|
||||||
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
|
sourceFormat, err := magic.ValidateMagicBytes(sourceData,
|
||||||
|
fetchResult.ContentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Warn("source refused", "request_id", middleware.GetReqID(ctx),
|
p.log.Warn("source refused", "request_id", middleware.GetReqID(ctx),
|
||||||
"host", req.Source.Host, "path", req.Source.Path,
|
"host", req.Source.Host, "path", req.Source.Path,
|
||||||
@@ -463,7 +459,7 @@ func (p *Proxy) fetchAndProcess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return p.processAndStore(
|
return p.processAndStore(
|
||||||
ctx, req, cacheKey, bytes.NewReader(sourceData), fetchBytes,
|
ctx, req, cacheKey, bytes.NewReader(sourceData), sourceFormat, fetchBytes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,13 +493,14 @@ type processedVariant struct {
|
|||||||
fetchedBytes int64
|
fetchedBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// processAndStore processes the image read from source and stores the
|
// processAndStore processes the image read from source, which is in
|
||||||
// result.
|
// sourceFormat, and stores the result.
|
||||||
func (p *Proxy) processAndStore(
|
func (p *Proxy) processAndStore(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *imagerequest.Request,
|
req *imagerequest.Request,
|
||||||
cacheKey imagerequest.VariantKey,
|
cacheKey imagerequest.VariantKey,
|
||||||
source io.Reader,
|
source io.Reader,
|
||||||
|
sourceFormat imagerequest.Format,
|
||||||
fetchBytes int64,
|
fetchBytes int64,
|
||||||
) (*processedVariant, error) {
|
) (*processedVariant, error) {
|
||||||
// Process the image
|
// Process the image
|
||||||
@@ -511,7 +508,8 @@ func (p *Proxy) processAndStore(
|
|||||||
|
|
||||||
// ctx has the deadline of the request doing the processing, which can
|
// ctx has the deadline of the request doing the processing, which can
|
||||||
// end while a request waiting for its image has time left
|
// end while a request waiting for its image has time left
|
||||||
processResult, err := p.processor.Process(ctx, source, &req.Transform)
|
processResult, err := p.processor.Process(ctx, source, sourceFormat,
|
||||||
|
&req.Transform)
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err)
|
return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err)
|
||||||
}
|
}
|
||||||
@@ -546,7 +544,7 @@ func (p *Proxy) processAndStore(
|
|||||||
"request_id", middleware.GetReqID(ctx),
|
"request_id", middleware.GetReqID(ctx),
|
||||||
"host", req.Source.Host,
|
"host", req.Source.Host,
|
||||||
"path", req.Source.Path,
|
"path", req.Source.Path,
|
||||||
"src_format", processResult.InputFormat,
|
"src_format", sourceFormat,
|
||||||
"dst_format", req.Transform.Format,
|
"dst_format", req.Transform.Format,
|
||||||
"src_bytes", fetchBytes,
|
"src_bytes", fetchBytes,
|
||||||
"dst_bytes", outputSize,
|
"dst_bytes", outputSize,
|
||||||
|
|||||||
@@ -191,23 +191,14 @@ func TestProxy_Get_FormatConversion(t *testing.T) {
|
|||||||
t.Fatalf("failed to read response: %v", err)
|
t.Fatalf("failed to read response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
detectedMIME, err := magic.DetectFormat(data)
|
detectedFormat, err := magic.DetectFormat(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to detect format: %v", err)
|
t.Fatalf("failed to detect format: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
|
if detectedFormat.MIMEType() != tt.wantMIME {
|
||||||
if !ok {
|
t.Errorf("detected format = %q, want the format of %s",
|
||||||
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
|
detectedFormat, tt.wantMIME)
|
||||||
}
|
|
||||||
|
|
||||||
detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
|
|
||||||
}
|
|
||||||
|
|
||||||
if detectedFormat != expectedFormat {
|
|
||||||
t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ func holdProcessingSlot(
|
|||||||
input, feed := io.Pipe()
|
input, feed := io.Pipe()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, _ = proc.Process(t.Context(), input, &imagerequest.Transform{})
|
_, _ = proc.Process(t.Context(), input, imagerequest.FormatJPEG,
|
||||||
|
&imagerequest.Transform{})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := feed.Write([]byte{0})
|
_, err := feed.Write([]byte{0})
|
||||||
@@ -57,10 +58,11 @@ func holdProcessingSlot(
|
|||||||
|
|
||||||
// TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a
|
// TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a
|
||||||
// request whose source is cached holds none of it while it waits for a
|
// request whose source is cached holds none of it while it waits for a
|
||||||
// processing slot: it reads the cached file only once it has a slot. With
|
// processing slot: past the first bytes, which give the source's format, it
|
||||||
// the only slot held, a request for a new width of the cached 100x100 photo
|
// reads the cached file only once it has a slot. With the only slot held, a
|
||||||
// waits; the cached file is then rewritten as a 100x50 image before the slot
|
// request for a new width of the cached 100x100 photo waits; the cached file
|
||||||
// is freed, so the request must answer with that image scaled to 40x20.
|
// is then rewritten as a 100x50 image before the slot is freed, so the
|
||||||
|
// request must answer with that image scaled to 40x20.
|
||||||
func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
cachedSource := widthOnlyRequest(fixtures, 50).Source
|
cachedSource := widthOnlyRequest(fixtures, 50).Source
|
||||||
|
|
||||||
reader, _, err := svc.cache.Source(t.Context(), cachedSource)
|
reader, _, _, err := svc.cache.Source(t.Context(), cachedSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Source() error = %v; want the cached source", err)
|
t.Fatalf("Source() error = %v; want the cached source", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestProxy_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
|||||||
|
|
||||||
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
|
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
|
||||||
|
|
||||||
reader, _, err := svc.cache.Source(t.Context(), source)
|
reader, _, _, err := svc.cache.Source(t.Context(), source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Source() error = %v, want the cached source", err)
|
t.Fatalf("Source() error = %v, want the cached source", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getImage calls Get for req and returns the image it answered with and
|
// getImage calls Get for req and returns the image it answered with and
|
||||||
@@ -49,7 +50,7 @@ func cachedSourcePath(
|
|||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
reader, _, err := svc.cache.Source(t.Context(), req.Source)
|
reader, _, _, err := svc.cache.Source(t.Context(), req.Source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Source() error = %v; want the cached source", err)
|
t.Fatalf("Source() error = %v; want the cached source", err)
|
||||||
}
|
}
|
||||||
@@ -104,6 +105,40 @@ func TestProxy_Get_FetchesAgainForRemovedCachedSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProxy_Get_FetchesAgainSourceOfNoSourceFormat replaces the cached
|
||||||
|
// source's file with text. The next request for a new size of the source
|
||||||
|
// must fetch it again and cache it whole.
|
||||||
|
func TestProxy_Get_FetchesAgainSourceOfNoSourceFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := setupTestProxy(t)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
|
||||||
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte("this is text, not an image"),
|
||||||
|
imgcache.StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replacing the source's file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||||
|
|
||||||
|
fetches := readCounters(t, svc).upstreamFetchCount
|
||||||
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
|
if fetches != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fileSize(t, path)
|
||||||
|
if size != wholeSize {
|
||||||
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProxy_Get_ConvertsAgainForRemovedCachedVariant caches a variant of
|
// TestProxy_Get_ConvertsAgainForRemovedCachedVariant caches a variant of
|
||||||
// the photo and removes its row and file, as the reconciliation pass does
|
// the photo and removes its row and file, as the reconciliation pass does
|
||||||
// when the file is empty or cut short. internal/imgcache tests that the
|
// when the file is empty or cut short. internal/imgcache tests that the
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package imagerequest holds the image request type, its parts, its checks,
|
// Package imagerequest holds the image request type, its parts, its checks,
|
||||||
// the parsing of the /v1/image/ path, the variant key, the source key and the
|
// the parsing of the /v1/image/ path, the variant key, the source key and the
|
||||||
// source's upstream URL. It imports no other package of this repo, so every
|
// source's upstream URL. It also holds the image format type and the MIME type
|
||||||
// package that handles an image request can import it.
|
// of each format. It imports no other package of this repo, so every package
|
||||||
|
// that handles an image request can import it.
|
||||||
package imagerequest
|
package imagerequest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,10 +13,11 @@ import (
|
|||||||
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
||||||
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
||||||
|
|
||||||
// Format represents supported output image formats.
|
// Format is an image format. pixa reads and writes the formats from JPEG to
|
||||||
|
// GIF below. In a request, orig and auto stand for one of them.
|
||||||
type Format string
|
type Format string
|
||||||
|
|
||||||
// Supported image output formats.
|
// Supported image formats.
|
||||||
const (
|
const (
|
||||||
FormatOriginal Format = "orig"
|
FormatOriginal Format = "orig"
|
||||||
FormatJPEG Format = "jpeg"
|
FormatJPEG Format = "jpeg"
|
||||||
@@ -31,6 +33,30 @@ const (
|
|||||||
FormatAuto Format = "auto"
|
FormatAuto Format = "auto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MIMEType returns the MIME type of the format, such as image/jpeg for
|
||||||
|
// FormatJPEG. orig and auto stand for another format, so they have none:
|
||||||
|
// MIMEType returns "" for them.
|
||||||
|
func (f Format) MIMEType() string {
|
||||||
|
switch f {
|
||||||
|
case FormatJPEG:
|
||||||
|
return "image/jpeg"
|
||||||
|
case FormatPNG:
|
||||||
|
return "image/png"
|
||||||
|
case FormatWebP:
|
||||||
|
return "image/webp"
|
||||||
|
case FormatAVIF:
|
||||||
|
return "image/avif"
|
||||||
|
case FormatJXL:
|
||||||
|
return "image/jxl"
|
||||||
|
case FormatGIF:
|
||||||
|
return "image/gif"
|
||||||
|
case FormatOriginal, FormatAuto:
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Size represents requested image dimensions
|
// Size represents requested image dimensions
|
||||||
type Size struct {
|
type Size struct {
|
||||||
Width int
|
Width int
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package imagerequest
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestFormat_MIMEType checks the MIME type of each format pixa reads and
|
||||||
|
// writes, and that orig and auto have none.
|
||||||
|
func TestFormat_MIMEType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
format Format
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{FormatJPEG, "image/jpeg"},
|
||||||
|
{FormatPNG, "image/png"},
|
||||||
|
{FormatWebP, "image/webp"},
|
||||||
|
{FormatAVIF, "image/avif"},
|
||||||
|
{FormatJXL, "image/jxl"},
|
||||||
|
{FormatGIF, "image/gif"},
|
||||||
|
{FormatOriginal, ""},
|
||||||
|
{FormatAuto, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := tt.format.MIMEType()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("%s MIMEType() = %q, want %q", tt.format, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-30
@@ -175,7 +175,7 @@ func (c *Cache) Variant(
|
|||||||
return nil, 0, "", err
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType, err := outputContentType(file)
|
format, err := readFormat(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ func (c *Cache) Variant(
|
|||||||
|
|
||||||
c.touchVariant(cacheKey)
|
c.touchVariant(cacheKey)
|
||||||
|
|
||||||
return file, size, contentType, nil
|
return file, size, format.MIMEType(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutVariant stores a processed variant by its cache key and records
|
// PutVariant stores a processed variant by its cache key and records
|
||||||
@@ -227,15 +227,17 @@ func (c *Cache) PutVariant(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source opens the cached source of src and returns a reader and its size
|
// Source opens the cached source of src and returns a reader, its size in
|
||||||
// in bytes. A source whose row or file is missing is ErrNotFound, as is any
|
// bytes and its format, read from the file's first bytes. A source whose row
|
||||||
// source of a disabled cache. A source it opens has its access time handed
|
// or file is missing is ErrNotFound, as is any source of a disabled cache; a
|
||||||
// to the background writer without waiting (see touchSourceContent).
|
// file whose first bytes are not those of a source format is another error.
|
||||||
|
// A source it opens has its access time handed to the background writer
|
||||||
|
// without waiting (see touchSourceContent).
|
||||||
func (c *Cache) Source(
|
func (c *Cache) Source(
|
||||||
ctx context.Context, src imagerequest.Source,
|
ctx context.Context, src imagerequest.Source,
|
||||||
) (io.ReadCloser, int64, error) {
|
) (io.ReadCloser, int64, imagerequest.Format, error) {
|
||||||
if c.config.MaxBytes == 0 {
|
if c.config.MaxBytes == 0 {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
key := src.Key()
|
key := src.Key()
|
||||||
@@ -246,21 +248,28 @@ func (c *Cache) Source(
|
|||||||
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
|
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
|
||||||
`, string(key)).Scan(&rowExists)
|
`, string(key)).Scan(&rowExists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("failed to lookup source: %w", err)
|
return nil, 0, "", fmt.Errorf("failed to lookup source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rowExists {
|
if !rowExists {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
file, size, err := c.srcContent.Open(string(key))
|
file, size, err := c.srcContent.Open(string(key))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
format, err := readFormat(file)
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.touchSourceContent(key)
|
c.touchSourceContent(key)
|
||||||
|
|
||||||
return file, size, nil
|
return file, size, format, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutSource stores fetched source content: it writes the file named by
|
// PutSource stores fetched source content: it writes the file named by
|
||||||
@@ -440,13 +449,10 @@ func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
|
|||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// errNotAnOutputFormat is returned for a variant file whose first bytes are
|
// readFormat reads the first bytes of a source or variant file, without
|
||||||
// those of a format pixa reads but never writes, such as SVG.
|
// moving its offset, and returns the format they show. Sources and variants
|
||||||
var errNotAnOutputFormat = errors.New("variant file is not in an output format")
|
// are in the same six formats.
|
||||||
|
func readFormat(content io.ReaderAt) (imagerequest.Format, error) {
|
||||||
// outputContentType reads the first bytes of content, without moving its
|
|
||||||
// offset, and returns the content type of the output format they show.
|
|
||||||
func outputContentType(content io.ReaderAt) (string, error) {
|
|
||||||
firstBytes := make([]byte, magic.MinMagicBytes)
|
firstBytes := make([]byte, magic.MinMagicBytes)
|
||||||
|
|
||||||
_, err := content.ReadAt(firstBytes, 0)
|
_, err := content.ReadAt(firstBytes, 0)
|
||||||
@@ -454,15 +460,5 @@ func outputContentType(content io.ReaderAt) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to read the first bytes: %w", err)
|
return "", fmt.Errorf("failed to read the first bytes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
detected, err := magic.DetectFormat(firstBytes)
|
return magic.DetectFormat(firstBytes)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, isOutputFormat := magic.MIMEToImageFormat(string(detected))
|
|
||||||
if !isOutputFormat {
|
|
||||||
return "", fmt.Errorf("%w: %s", errNotAnOutputFormat, detected)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(detected), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
|||||||
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
} {
|
} {
|
||||||
result, err := processor.Process(t.Context(), bytes.NewReader(source),
|
result, err := processor.Process(t.Context(), bytes.NewReader(source),
|
||||||
&imagerequest.Transform{
|
imagerequest.FormatJPEG, &imagerequest.Transform{
|
||||||
Size: imagerequest.Size{Width: 20, Height: 15},
|
Size: imagerequest.Size{Width: 20, Height: 15},
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -68,7 +68,7 @@ func TestVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
||||||
|
|
||||||
if want := imageprocessor.FormatToMIME(format); contentType != want {
|
if want := format.MIMEType(); contentType != want {
|
||||||
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ func TestVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
||||||
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
errNotAnOutputFormat},
|
magic.ErrUnknownFormat},
|
||||||
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
||||||
{"empty", nil, io.EOF},
|
{"empty", nil, io.EOF},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ func assertDisabledCacheReadsAlwaysMiss(
|
|||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
reader, _, err := cache.Source(ctx, req.Source)
|
reader, _, _, err := cache.Source(ctx, req.Source)
|
||||||
if !errors.Is(err, ErrNotFound) {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("Source on disabled cache error = %v, want %v", err, ErrNotFound)
|
t.Fatalf("Source on disabled cache error = %v, want %v", err, ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -204,8 +205,8 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg",
|
content := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
|
||||||
[]byte("source bytes"))
|
key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg", content)
|
||||||
anHourAgo := time.Now().Add(-time.Hour)
|
anHourAgo := time.Now().Add(-time.Hour)
|
||||||
setSourceLastAccessed(t, cache, key, anHourAgo)
|
setSourceLastAccessed(t, cache, key, anHourAgo)
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
|
|||||||
`
|
`
|
||||||
anHourAgoInDatabase := anHourAgo.UTC().Format(sqliteTimestampFormat)
|
anHourAgoInDatabase := anHourAgo.UTC().Format(sqliteTimestampFormat)
|
||||||
|
|
||||||
reader, _, err := cache.Source(t.Context(),
|
reader, _, _, err := cache.Source(t.Context(),
|
||||||
imagerequest.Source{Host: "example.com", Path: "/photo.jpg"})
|
imagerequest.Source{Host: "example.com", Path: "/photo.jpg"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Source() error = %v; want the source of %q", err, key)
|
t.Fatalf("Source() error = %v; want the source of %q", err, key)
|
||||||
@@ -224,7 +225,7 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
|
|||||||
found, err := io.ReadAll(reader)
|
found, err := io.ReadAll(reader)
|
||||||
_ = reader.Close()
|
_ = reader.Close()
|
||||||
|
|
||||||
if err != nil || string(found) != "source bytes" {
|
if err != nil || !bytes.Equal(found, content) {
|
||||||
t.Fatalf("Source() reads %q, %v; want the source of %q", found, err, key)
|
t.Fatalf("Source() reads %q, %v; want the source of %q", found, err, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestPutSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes
|
// TestPutSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes
|
||||||
@@ -64,7 +69,7 @@ func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
content := bytes.Repeat([]byte{0x5B}, 400)
|
content := generateTestJPEG(t, 20, 20, color.RGBA{0x5B, 0x5B, 0x5B, 255})
|
||||||
|
|
||||||
evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content)
|
evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content)
|
||||||
storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content)
|
storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content)
|
||||||
@@ -85,7 +90,7 @@ func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) {
|
|||||||
t.Errorf("the evicted source still has %d source_content rows", n)
|
t.Errorf("the evicted source still has %d source_content rows", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, _, err := cache.Source(t.Context(),
|
reader, _, _, err := cache.Source(t.Context(),
|
||||||
imagerequest.Source{Host: testHostCDN, Path: "/kept.jpg"})
|
imagerequest.Source{Host: testHostCDN, Path: "/kept.jpg"})
|
||||||
t.Logf("Source() of the kept source: error %v", err)
|
t.Logf("Source() of the kept source: error %v", err)
|
||||||
|
|
||||||
@@ -135,3 +140,99 @@ func TestPutSource_FailedRowWriteLeavesNoFile(t *testing.T) {
|
|||||||
t.Error("the source's file is on disk although its row was not written")
|
t.Error("the source's file is on disk although its row was not written")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSource_FormatFromFirstBytes stores an image in each source format
|
||||||
|
// under one source URL, in turn. After each store, Source must return that
|
||||||
|
// image whole, with its format read from the file.
|
||||||
|
func TestSource_FormatFromFirstBytes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
processor := imageprocessor.New(imageprocessor.Params{
|
||||||
|
MaxInputBytes: 50 << 20,
|
||||||
|
MaxConcurrentProcessing: 1,
|
||||||
|
})
|
||||||
|
jpegImage := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
|
||||||
|
src := imagerequest.Source{Host: testHostCDN, Path: testPathCat}
|
||||||
|
|
||||||
|
for _, format := range []imagerequest.Format{
|
||||||
|
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||||
|
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||||
|
} {
|
||||||
|
result, err := processor.Process(t.Context(), bytes.NewReader(jpegImage),
|
||||||
|
imagerequest.FormatJPEG, &imagerequest.Transform{Format: format})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("making a %s image: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the %s image: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cache.PutSource(t.Context(), src, bytes.NewReader(image))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutSource(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, size, got, err := cache.Source(t.Context(), src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Source() of the %s image error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
served, err := io.ReadAll(reader)
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the %s source: %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%s: %d bytes read as %s", format, size, got)
|
||||||
|
|
||||||
|
if got != format {
|
||||||
|
t.Errorf("Source() of the %s image format = %q, want %q",
|
||||||
|
format, got, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size != int64(len(image)) || !bytes.Equal(served, image) {
|
||||||
|
t.Errorf("%s source read as %d bytes, size %d, want the %d bytes stored",
|
||||||
|
format, len(served), size, len(image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSource_RefusesFileOfNoSourceFormat checks that Source refuses a
|
||||||
|
// cached source whose file's first bytes are not those of a source format,
|
||||||
|
// with an error other than ErrNotFound.
|
||||||
|
func TestSource_RefusesFileOfNoSourceFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
||||||
|
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
|
magic.ErrUnknownFormat},
|
||||||
|
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
||||||
|
{"empty file", nil, io.EOF},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tests {
|
||||||
|
path := fmt.Sprintf("/refused%d.jpg", i)
|
||||||
|
storeEvictionTestSource(t, cache, testHostCDN, path, tt.content)
|
||||||
|
|
||||||
|
reader, _, format, err := cache.Source(t.Context(),
|
||||||
|
imagerequest.Source{Host: testHostCDN, Path: path})
|
||||||
|
t.Logf("%s: Source() error = %v", tt.name, err)
|
||||||
|
|
||||||
|
if reader != nil || format != "" || !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("%s: Source() = reader %v, format %q, error %v; "+
|
||||||
|
"want no reader and %v", tt.name, reader, format, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package magic
|
package magic
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
|
)
|
||||||
|
|
||||||
// testMIMEJXL is the MIME type of JPEG XL.
|
// testMIMEJXL is the MIME type of JPEG XL.
|
||||||
const testMIMEJXL = "image/jxl"
|
const testMIMEJXL = "image/jxl"
|
||||||
|
|
||||||
// TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a
|
// TestDetectFormatJPEGXL verifies that both JPEG XL signatures, that of a
|
||||||
// bare codestream and that of the container, are detected as image/jxl.
|
// bare codestream and that of the container, are detected as JPEG XL and
|
||||||
|
// match the MIME type image/jxl.
|
||||||
func TestDetectFormatJPEGXL(t *testing.T) {
|
func TestDetectFormatJPEGXL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -25,25 +30,15 @@ func TestDetectFormatJPEGXL(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
got, err := DetectFormat(tt.data)
|
got, err := DetectFormat(tt.data)
|
||||||
if err != nil || got != MIMETypeJXL {
|
if err != nil || got != imagerequest.FormatJXL {
|
||||||
t.Errorf("DetectFormat() = %q, %v, want %q", got, err, testMIMEJXL)
|
t.Errorf("DetectFormat() = %q, %v, want %q",
|
||||||
|
got, err, imagerequest.FormatJXL)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidateMagicBytes(tt.data, testMIMEJXL)
|
_, err = ValidateMagicBytes(tt.data, testMIMEJXL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err)
|
t.Errorf("ValidateMagicBytes(%q) error = %v", testMIMEJXL, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestJPEGXLMIMEType verifies that image/jxl maps to the format jxl.
|
|
||||||
func TestJPEGXLMIMEType(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
format, ok := MIMEToImageFormat(testMIMEJXL)
|
|
||||||
if format != FormatJXL || !ok {
|
|
||||||
t.Errorf("MIMEToImageFormat(%q) = %q, %v, want %q, true",
|
|
||||||
testMIMEJXL, format, ok, FormatJXL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+21
-98
@@ -6,6 +6,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Magic byte errors.
|
// Magic byte errors.
|
||||||
@@ -15,34 +17,6 @@ var (
|
|||||||
ErrNotEnoughData = errors.New("not enough data to detect format")
|
ErrNotEnoughData = errors.New("not enough data to detect format")
|
||||||
)
|
)
|
||||||
|
|
||||||
// MIMEType represents a supported MIME type for input images.
|
|
||||||
type MIMEType string
|
|
||||||
|
|
||||||
// Supported input MIME types.
|
|
||||||
const (
|
|
||||||
MIMETypeJPEG = MIMEType("image/jpeg")
|
|
||||||
MIMETypePNG = MIMEType("image/png")
|
|
||||||
MIMETypeWebP = MIMEType("image/webp")
|
|
||||||
MIMETypeGIF = MIMEType("image/gif")
|
|
||||||
MIMETypeAVIF = MIMEType("image/avif")
|
|
||||||
MIMETypeJXL = MIMEType("image/jxl")
|
|
||||||
MIMETypeSVG = MIMEType("image/svg+xml")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ImageFormat represents supported output image formats.
|
|
||||||
// This mirrors the type in imgcache to avoid circular imports.
|
|
||||||
type ImageFormat string
|
|
||||||
|
|
||||||
// Supported image output formats.
|
|
||||||
const (
|
|
||||||
FormatJPEG ImageFormat = "jpeg"
|
|
||||||
FormatPNG ImageFormat = "png"
|
|
||||||
FormatWebP ImageFormat = "webp"
|
|
||||||
FormatAVIF ImageFormat = "avif"
|
|
||||||
FormatJXL ImageFormat = "jxl"
|
|
||||||
FormatGIF ImageFormat = "gif"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
||||||
const MinMagicBytes = 12
|
const MinMagicBytes = 12
|
||||||
|
|
||||||
@@ -78,32 +52,32 @@ var (
|
|||||||
avisBrand = []byte{0x61, 0x76, 0x69, 0x73} // "avis" (AVIF sequence)
|
avisBrand = []byte{0x61, 0x76, 0x69, 0x73} // "avis" (AVIF sequence)
|
||||||
)
|
)
|
||||||
|
|
||||||
// DetectFormat detects the image format from magic bytes.
|
// DetectFormat detects the image format from magic bytes. Data in none of
|
||||||
// Returns the MIME type and nil error on success.
|
// the formats pixa reads, SVG included, is ErrUnknownFormat.
|
||||||
func DetectFormat(data []byte) (MIMEType, error) {
|
func DetectFormat(data []byte) (imagerequest.Format, error) {
|
||||||
if len(data) < MinMagicBytes {
|
if len(data) < MinMagicBytes {
|
||||||
return "", ErrNotEnoughData
|
return "", ErrNotEnoughData
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check JPEG (FFD8FF)
|
// Check JPEG (FFD8FF)
|
||||||
if bytes.HasPrefix(data, magicJPEG) {
|
if bytes.HasPrefix(data, magicJPEG) {
|
||||||
return MIMETypeJPEG, nil
|
return imagerequest.FormatJPEG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check PNG (89504E47 0D0A1A0A)
|
// Check PNG (89504E47 0D0A1A0A)
|
||||||
if bytes.HasPrefix(data, magicPNG) {
|
if bytes.HasPrefix(data, magicPNG) {
|
||||||
return MIMETypePNG, nil
|
return imagerequest.FormatPNG, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check GIF (GIF87a or GIF89a)
|
// Check GIF (GIF87a or GIF89a)
|
||||||
if bytes.HasPrefix(data, magicGIF) {
|
if bytes.HasPrefix(data, magicGIF) {
|
||||||
return MIMETypeGIF, nil
|
return imagerequest.FormatGIF, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check WebP (RIFF....WEBP)
|
// Check WebP (RIFF....WEBP)
|
||||||
if bytes.HasPrefix(data, magicWebP) && len(data) >= 12 {
|
if bytes.HasPrefix(data, magicWebP) && len(data) >= 12 {
|
||||||
if bytes.Equal(data[8:12], webpIdent) {
|
if bytes.Equal(data[8:12], webpIdent) {
|
||||||
return MIMETypeWebP, nil
|
return imagerequest.FormatWebP, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,66 +86,39 @@ func DetectFormat(data []byte) (MIMEType, error) {
|
|||||||
if len(data) >= 12 && bytes.Equal(data[4:8], magicFtyp) {
|
if len(data) >= 12 && bytes.Equal(data[4:8], magicFtyp) {
|
||||||
brand := data[8:12]
|
brand := data[8:12]
|
||||||
if bytes.Equal(brand, avifBrand) || bytes.Equal(brand, avisBrand) {
|
if bytes.Equal(brand, avifBrand) || bytes.Equal(brand, avisBrand) {
|
||||||
return MIMETypeAVIF, nil
|
return imagerequest.FormatAVIF, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check JPEG XL (FF0A, or the 12-byte container signature)
|
// Check JPEG XL (FF0A, or the 12-byte container signature)
|
||||||
if bytes.HasPrefix(data, magicJXLCodestream) ||
|
if bytes.HasPrefix(data, magicJXLCodestream) ||
|
||||||
bytes.HasPrefix(data, magicJXLContainer) {
|
bytes.HasPrefix(data, magicJXLContainer) {
|
||||||
return MIMETypeJXL, nil
|
return imagerequest.FormatJXL, nil
|
||||||
}
|
|
||||||
|
|
||||||
// Check SVG - look for XML declaration or SVG tag
|
|
||||||
if detectSVG(data) {
|
|
||||||
return MIMETypeSVG, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", ErrUnknownFormat
|
return "", ErrUnknownFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectSVG checks if data appears to be SVG content.
|
// ValidateMagicBytes detects the format of data from its magic bytes, and
|
||||||
func detectSVG(data []byte) bool {
|
// returns it when it is the format the declared MIME type names. Data in
|
||||||
// Skip BOM if present
|
// another format is ErrMagicByteMismatch.
|
||||||
content := skipBOM(data)
|
func ValidateMagicBytes(
|
||||||
|
data []byte, declaredType string,
|
||||||
// Convert to string for easier pattern matching
|
) (imagerequest.Format, error) {
|
||||||
s := strings.ToLower(string(content))
|
|
||||||
|
|
||||||
// Skip leading whitespace
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
|
|
||||||
// Check for XML declaration or SVG element
|
|
||||||
return strings.HasPrefix(s, "<?xml") ||
|
|
||||||
strings.HasPrefix(s, "<svg") ||
|
|
||||||
strings.HasPrefix(s, "<!doctype svg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// skipBOM removes UTF-8 BOM if present.
|
|
||||||
func skipBOM(data []byte) []byte {
|
|
||||||
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
|
|
||||||
return data[3:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateMagicBytes validates that the content matches the declared MIME type.
|
|
||||||
func ValidateMagicBytes(data []byte, declaredType string) error {
|
|
||||||
detected, err := DetectFormat(data)
|
detected, err := DetectFormat(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize the declared type (remove parameters like charset)
|
// Normalize the declared type (remove parameters like charset)
|
||||||
normalizedDeclared := normalizeMIMEType(declaredType)
|
normalizedDeclared := normalizeMIMEType(declaredType)
|
||||||
|
|
||||||
// Check if they match
|
// Check if they match
|
||||||
if string(detected) != normalizedDeclared {
|
if detected.MIMEType() != normalizedDeclared {
|
||||||
return ErrMagicByteMismatch
|
return "", ErrMagicByteMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return detected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeMIMEType extracts just the media type, removing parameters.
|
// normalizeMIMEType extracts just the media type, removing parameters.
|
||||||
@@ -183,27 +130,3 @@ func normalizeMIMEType(mimeType string) string {
|
|||||||
|
|
||||||
return strings.TrimSpace(strings.ToLower(mimeType))
|
return strings.TrimSpace(strings.ToLower(mimeType))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIMEToImageFormat converts a MIME type to an ImageFormat.
|
|
||||||
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
|
||||||
normalized := normalizeMIMEType(mimeType)
|
|
||||||
switch MIMEType(normalized) {
|
|
||||||
case MIMETypeJPEG:
|
|
||||||
return FormatJPEG, true
|
|
||||||
case MIMETypePNG:
|
|
||||||
return FormatPNG, true
|
|
||||||
case MIMETypeWebP:
|
|
||||||
return FormatWebP, true
|
|
||||||
case MIMETypeGIF:
|
|
||||||
return FormatGIF, true
|
|
||||||
case MIMETypeAVIF:
|
|
||||||
return FormatAVIF, true
|
|
||||||
case MIMETypeJXL:
|
|
||||||
return FormatJXL, true
|
|
||||||
case MIMETypeSVG:
|
|
||||||
// SVG has no corresponding output format.
|
|
||||||
return "", false
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package magic
|
package magic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared test fixture strings.
|
// Shared test fixture strings.
|
||||||
@@ -12,9 +13,6 @@ const (
|
|||||||
testMIMEJPEG = "image/jpeg"
|
testMIMEJPEG = "image/jpeg"
|
||||||
testMIMEJPEGParams = "image/jpeg; charset=utf-8"
|
testMIMEJPEGParams = "image/jpeg; charset=utf-8"
|
||||||
testMIMEPNG = "image/png"
|
testMIMEPNG = "image/png"
|
||||||
testMIMEWebP = "image/webp"
|
|
||||||
testMIMEGIF = "image/gif"
|
|
||||||
testMIMEAVIF = "image/avif"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// pad appends zero bytes so data is comfortably above MinMagicBytes.
|
// pad appends zero bytes so data is comfortably above MinMagicBytes.
|
||||||
@@ -38,35 +36,20 @@ func TestDetectFormat(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
wantMIME MIMEType
|
wantFormat imagerequest.Format
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{name: "JPEG", data: jpeg, wantMIME: MIMETypeJPEG},
|
{name: "JPEG", data: jpeg, wantFormat: imagerequest.FormatJPEG},
|
||||||
{name: "PNG", data: png, wantMIME: MIMETypePNG},
|
{name: "PNG", data: png, wantFormat: imagerequest.FormatPNG},
|
||||||
{name: "GIF87a", data: gif87a, wantMIME: MIMETypeGIF},
|
{name: "GIF87a", data: gif87a, wantFormat: imagerequest.FormatGIF},
|
||||||
{name: "GIF89a", data: gif89a, wantMIME: MIMETypeGIF},
|
{name: "GIF89a", data: gif89a, wantFormat: imagerequest.FormatGIF},
|
||||||
{name: "WebP", data: webp, wantMIME: MIMETypeWebP},
|
{name: "WebP", data: webp, wantFormat: imagerequest.FormatWebP},
|
||||||
{name: "AVIF", data: avif, wantMIME: MIMETypeAVIF},
|
{name: "AVIF", data: avif, wantFormat: imagerequest.FormatAVIF},
|
||||||
{name: "AVIF sequence", data: avis, wantMIME: MIMETypeAVIF},
|
{name: "AVIF sequence", data: avis, wantFormat: imagerequest.FormatAVIF},
|
||||||
{
|
{
|
||||||
name: "SVG with XML declaration",
|
name: "SVG",
|
||||||
data: []byte(`<?xml version="1.0"?><svg></svg>`),
|
|
||||||
wantMIME: MIMETypeSVG,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SVG without declaration",
|
|
||||||
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantErr: ErrUnknownFormat,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SVG with whitespace",
|
|
||||||
data: []byte(` <?xml version="1.0"?><svg></svg>`),
|
|
||||||
wantMIME: MIMETypeSVG,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SVG with BOM",
|
|
||||||
data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...),
|
|
||||||
wantMIME: MIMETypeSVG,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown format",
|
name: "unknown format",
|
||||||
@@ -88,8 +71,8 @@ func TestDetectFormat(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if got != tt.wantMIME {
|
if got != tt.wantFormat {
|
||||||
t.Errorf("DetectFormat() = %v, want %v", got, tt.wantMIME)
|
t.Errorf("DetectFormat() = %v, want %v", got, tt.wantFormat)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -105,24 +88,28 @@ func TestValidateMagicBytes(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
declaredType string
|
declaredType string
|
||||||
|
wantFormat imagerequest.Format
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "matching JPEG",
|
name: "matching JPEG",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: testMIMEJPEG,
|
declaredType: testMIMEJPEG,
|
||||||
|
wantFormat: imagerequest.FormatJPEG,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matching JPEG with params",
|
name: "matching JPEG with params",
|
||||||
data: jpegData,
|
data: jpegData,
|
||||||
declaredType: testMIMEJPEGParams,
|
declaredType: testMIMEJPEGParams,
|
||||||
|
wantFormat: imagerequest.FormatJPEG,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "matching PNG",
|
name: "matching PNG",
|
||||||
data: pngData,
|
data: pngData,
|
||||||
declaredType: testMIMEPNG,
|
declaredType: testMIMEPNG,
|
||||||
|
wantFormat: imagerequest.FormatPNG,
|
||||||
wantErr: nil,
|
wantErr: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -143,45 +130,14 @@ func TestValidateMagicBytes(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
err := ValidateMagicBytes(tt.data, tt.declaredType)
|
got, err := ValidateMagicBytes(tt.data, tt.declaredType)
|
||||||
|
|
||||||
if !errors.Is(err, tt.wantErr) {
|
if !errors.Is(err, tt.wantErr) {
|
||||||
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMIMEToImageFormat(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
mimeType string
|
|
||||||
wantFormat ImageFormat
|
|
||||||
wantOk bool
|
|
||||||
}{
|
|
||||||
{testMIMEJPEG, FormatJPEG, true},
|
|
||||||
{testMIMEPNG, FormatPNG, true},
|
|
||||||
{testMIMEWebP, FormatWebP, true},
|
|
||||||
{testMIMEGIF, FormatGIF, true},
|
|
||||||
{testMIMEAVIF, FormatAVIF, true},
|
|
||||||
{"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat
|
|
||||||
{"image/tiff", "", false},
|
|
||||||
{"text/plain", "", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.mimeType, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got, ok := MIMEToImageFormat(tt.mimeType)
|
|
||||||
|
|
||||||
if ok != tt.wantOk {
|
|
||||||
t.Errorf("MIMEToImageFormat(%q) ok = %v, want %v", tt.mimeType, ok, tt.wantOk)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != tt.wantFormat {
|
if got != tt.wantFormat {
|
||||||
t.Errorf("MIMEToImageFormat(%q) = %v, want %v", tt.mimeType, got, tt.wantFormat)
|
t.Errorf("ValidateMagicBytes() = %q, want %q", got, tt.wantFormat)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -214,109 +170,6 @@ func TestNormalizeMIMEType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectSVG(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"xml declaration", `<?xml version="1.0"?><svg></svg>`, true},
|
|
||||||
{"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true},
|
|
||||||
{
|
|
||||||
"doctype",
|
|
||||||
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
|
|
||||||
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`,
|
|
||||||
true,
|
|
||||||
},
|
|
||||||
{"with whitespace", `
|
|
||||||
<?xml version="1.0"?><svg></svg>`, true},
|
|
||||||
{"uppercase", `<SVG></SVG>`, true},
|
|
||||||
{"not svg", `<html></html>`, false},
|
|
||||||
{"random text", `hello world`, false},
|
|
||||||
{testNameEmpty, ``, false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got := detectSVG([]byte(tt.data))
|
|
||||||
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("detectSVG(%q) = %v, want %v", tt.data, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSkipBOM(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data []byte
|
|
||||||
want []byte
|
|
||||||
}{
|
|
||||||
{"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")},
|
|
||||||
{"without BOM", []byte("hello"), []byte("hello")},
|
|
||||||
{testNameEmpty, []byte{}, []byte{}},
|
|
||||||
{"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}},
|
|
||||||
{"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got := skipBOM(tt.data)
|
|
||||||
|
|
||||||
if !bytes.Equal(got, tt.want) {
|
|
||||||
t.Errorf("skipBOM() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRealWorldSVGPatterns(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Test various real-world SVG patterns
|
|
||||||
svgPatterns := []string{
|
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<circle cx="50" cy="50" r="40"/>
|
|
||||||
</svg>`,
|
|
||||||
`<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|
||||||
</svg>`,
|
|
||||||
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
|
|
||||||
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` + `
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
</svg>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, pattern := range svgPatterns {
|
|
||||||
data := []byte(pattern)
|
|
||||||
if len(data) < MinMagicBytes {
|
|
||||||
// Pad short SVGs for detection
|
|
||||||
data = append(data, make([]byte, MinMagicBytes-len(data))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := DetectFormat(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Pattern %d: DetectFormat() error = %v", i, err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if got != MIMETypeSVG {
|
|
||||||
t.Errorf("Pattern %d: DetectFormat() = %v, want %v", i, got, MIMETypeSVG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
func TestDetectFormatRIFFNotWebP(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user