Compare commits
1
Commits
next
...
adfb6ab50a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adfb6ab50a |
@@ -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. A source image's row also holds its
|
||||||
bytes are stored twice, once under each URL. A source image is stored by writing
|
format, which pixa reads from the image's first bytes when it fetches it. Two
|
||||||
its file and then its row; when the row cannot be written, the file is removed
|
source URLs that serve the same bytes are stored twice, once under each URL. A
|
||||||
again.
|
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,28 @@ 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.
|
||||||
|
- The cache stores a source's format in its `source_content` row, and
|
||||||
|
`Cache.Source` returns it. `Cache.PutSource` takes it.
|
||||||
|
- The proxy hands the format to `ImageProcessor.Process`. The processor
|
||||||
|
refuses input that libvips would load as another format, as before, with
|
||||||
|
`ErrUnsupportedInputFormat`.
|
||||||
|
- `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>`.
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func writeConcurrently(t *testing.T, db *sql.DB) {
|
|||||||
// record again.
|
// record again.
|
||||||
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
||||||
_, err := db.ExecContext(ctx, `INSERT INTO source_content
|
_, err := db.ExecContext(ctx, `INSERT INTO source_content
|
||||||
(source_key, size_bytes) VALUES (?, 1)`, key)
|
(source_key, size_bytes, format) VALUES (?, 1, 'jpeg')`, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storing a source: %w", err)
|
return fmt.Errorf("storing a source: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
-- Source images, one row per source URL
|
-- Source images, one row per source URL
|
||||||
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
|
||||||
-- is the SHA-256 of the source's host, path and query
|
-- is the SHA-256 of the source's host, path and query. format is the
|
||||||
|
-- image's format, detected from its first bytes when it was fetched.
|
||||||
CREATE TABLE IF NOT EXISTS source_content (
|
CREATE TABLE IF NOT EXISTS source_content (
|
||||||
source_key TEXT PRIMARY KEY,
|
source_key TEXT PRIMARY KEY,
|
||||||
size_bytes INTEGER NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
|
|||||||
@@ -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,9 @@ 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.
|
||||||
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 +155,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 +167,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)
|
||||||
@@ -183,14 +183,13 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// govips picks the libvips loader from the input's first bytes with
|
// govips picks the libvips loader from the input's first bytes with
|
||||||
// DetermineImageType. Input whose first bytes it reads as no source
|
// DetermineImageType. Input it would not load with the loader of
|
||||||
// format, such as SVG, which librsvg would render, is refused before
|
// sourceFormat is refused before libvips reads it. An SVG whose first
|
||||||
// libvips reads it.
|
// bytes pass the first-bytes check for AVIF is one, which librsvg would
|
||||||
inputFormat := p.detectFormat(vips.DetermineImageType(data))
|
// otherwise render.
|
||||||
|
if vips.DetermineImageType(data) != vipsImageType(sourceFormat) {
|
||||||
sourceFormat, err := p.formatFromString(inputFormat)
|
return nil, fmt.Errorf("%w: libvips does not read the input as %s",
|
||||||
if err != nil {
|
ErrUnsupportedInputFormat, sourceFormat)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode image
|
// Decode image
|
||||||
@@ -254,12 +253,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 +292,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 +360,27 @@ 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.
|
// vipsImageType returns the govips image type whose libvips loader reads
|
||||||
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
// format. orig and auto stand for another format, so they have
|
||||||
switch imageType {
|
// vips.ImageTypeUnknown, which govips has no loader for.
|
||||||
case vips.ImageTypeJPEG:
|
func vipsImageType(format imagerequest.Format) vips.ImageType {
|
||||||
return "jpeg"
|
switch format {
|
||||||
case vips.ImageTypePNG:
|
case imagerequest.FormatJPEG:
|
||||||
return "png"
|
return vips.ImageTypeJPEG
|
||||||
case vips.ImageTypeGIF:
|
case imagerequest.FormatPNG:
|
||||||
return "gif"
|
return vips.ImageTypePNG
|
||||||
case vips.ImageTypeWEBP:
|
case imagerequest.FormatGIF:
|
||||||
return "webp"
|
return vips.ImageTypeGIF
|
||||||
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
|
case imagerequest.FormatWebP:
|
||||||
return string(imagerequest.FormatAVIF)
|
return vips.ImageTypeWEBP
|
||||||
case vips.ImageTypeJXL:
|
case imagerequest.FormatAVIF:
|
||||||
return string(imagerequest.FormatJXL)
|
return vips.ImageTypeAVIF
|
||||||
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
|
case imagerequest.FormatJXL:
|
||||||
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
|
return vips.ImageTypeJXL
|
||||||
vips.ImageTypeJP2K:
|
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||||
return "unknown"
|
return vips.ImageTypeUnknown
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return vips.ImageTypeUnknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,26 +734,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 {
|
||||||
|
|||||||
@@ -21,7 +21,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 +32,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 +42,9 @@ 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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,20 +304,20 @@ func withoutCancelKeepingDeadline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadCachedSource opens src's cached source, without reading it, and
|
// loadCachedSource opens src's cached source, without reading it, and
|
||||||
// returns it with its size; nil if it is not in the cache, cannot be read,
|
// returns it with its size and its format; nil if it is not in the cache,
|
||||||
// is empty or exceeds maxResponseSize.
|
// cannot be read, is empty 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 +330,16 @@ func (p *Proxy) loadCachedSource(
|
|||||||
"max_bytes", p.maxResponseSize,
|
"max_bytes", p.maxResponseSize,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0
|
return nil, 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
_ = reader.Close()
|
_ = reader.Close()
|
||||||
|
|
||||||
return nil, 0
|
return nil, 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, size
|
return reader, size, format
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFromSourceOrFetch processes an image, using cached source content
|
// processFromSourceOrFetch processes an image, using cached source content
|
||||||
@@ -349,7 +349,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 +361,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 +442,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,
|
||||||
@@ -456,14 +457,15 @@ func (p *Proxy) fetchAndProcess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store source content
|
// Store source content
|
||||||
_, err = p.cache.PutSource(ctx, req.Source, bytes.NewReader(sourceData))
|
_, err = p.cache.PutSource(ctx, req.Source, sourceFormat,
|
||||||
|
bytes.NewReader(sourceData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.log.Warn("failed to store source content", "error", err)
|
p.log.Warn("failed to store source content", "error", err)
|
||||||
// Continue even if caching fails
|
// Continue even if caching fails
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.processAndStore(
|
return p.processAndStore(
|
||||||
ctx, req, cacheKey, bytes.NewReader(sourceData), fetchBytes,
|
ctx, req, cacheKey, bytes.NewReader(sourceData), sourceFormat, fetchBytes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,13 +499,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 +514,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 +550,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})
|
||||||
@@ -79,7 +80,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-40
@@ -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,44 +227,46 @@ 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, the one PutSource stored with it. 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).
|
// 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()
|
||||||
|
|
||||||
var rowExists bool
|
var format imagerequest.Format
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?)
|
SELECT format FROM source_content WHERE source_key = ?
|
||||||
`, string(key)).Scan(&rowExists)
|
`, string(key)).Scan(&format)
|
||||||
if err != nil {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, 0, fmt.Errorf("failed to lookup source: %w", err)
|
return nil, 0, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rowExists {
|
if err != nil {
|
||||||
return nil, 0, ErrNotFound
|
return nil, 0, "", fmt.Errorf("failed to lookup source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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 and its format, which magic
|
||||||
// the source's key, then the source's row. When the row cannot be written
|
// detected from its first bytes. It writes the file named by the source's
|
||||||
|
// key, then the source's row with the format. When the row cannot be written
|
||||||
// the file is removed and the store fails, as a file with no row is never
|
// the file is removed and the store fails, as a file with no row is never
|
||||||
// served or evicted; if that removal fails too, the next reconciliation
|
// served or evicted; if that removal fails too, the next reconciliation
|
||||||
// pass removes the file. On a disabled cache it is a no-op returning an
|
// pass removes the file. On a disabled cache it is a no-op returning an
|
||||||
@@ -272,6 +274,7 @@ func (c *Cache) Source(
|
|||||||
func (c *Cache) PutSource(
|
func (c *Cache) PutSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
src imagerequest.Source,
|
src imagerequest.Source,
|
||||||
|
format imagerequest.Format,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
) (imagerequest.SourceKey, error) {
|
) (imagerequest.SourceKey, error) {
|
||||||
if c.config.MaxBytes == 0 {
|
if c.config.MaxBytes == 0 {
|
||||||
@@ -293,12 +296,13 @@ func (c *Cache) PutSource(
|
|||||||
size := int64(len(data))
|
size := int64(len(data))
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err = c.db.ExecContext(ctx, `
|
||||||
INSERT INTO source_content (source_key, size_bytes)
|
INSERT INTO source_content (source_key, size_bytes, format)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(source_key) DO UPDATE SET
|
ON CONFLICT(source_key) DO UPDATE SET
|
||||||
size_bytes = excluded.size_bytes,
|
size_bytes = excluded.size_bytes,
|
||||||
|
format = excluded.format,
|
||||||
last_accessed_at = CURRENT_TIMESTAMP
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
`, string(key), size)
|
`, string(key), size, string(format))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deleteErr := c.srcContent.Delete(string(key))
|
deleteErr := c.srcContent.Delete(string(key))
|
||||||
if deleteErr != nil {
|
if deleteErr != nil {
|
||||||
@@ -440,13 +444,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 content, without moving its offset,
|
||||||
// those of a format pixa reads but never writes, such as SVG.
|
// and returns the format they show. pixa writes each format it reads, so
|
||||||
var errNotAnOutputFormat = errors.New("variant file is not in an output format")
|
// that is an output format.
|
||||||
|
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 +455,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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
return cache, tmpDir
|
return cache, tmpDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestSource stores content as a fetched source for
|
// storeEvictionTestSource stores content as a fetched JPEG source for
|
||||||
// host/path and returns the resulting source key.
|
// host/path and returns the resulting source key. The cache does not read
|
||||||
|
// the content, so it need not be a JPEG.
|
||||||
func storeEvictionTestSource(
|
func storeEvictionTestSource(
|
||||||
t *testing.T, cache *Cache, host, path string, content []byte,
|
t *testing.T, cache *Cache, host, path string, content []byte,
|
||||||
) imagerequest.SourceKey {
|
) imagerequest.SourceKey {
|
||||||
@@ -99,7 +100,8 @@ func storeEvictionTestSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
key, err := cache.PutSource(
|
key, err := cache.PutSource(
|
||||||
context.Background(), req.Source, bytes.NewReader(content),
|
context.Background(), req.Source, imagerequest.FormatJPEG,
|
||||||
|
bytes.NewReader(content),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PutSource(%s%s) failed: %v", host, path, err)
|
t.Fatalf("PutSource(%s%s) failed: %v", host, path, err)
|
||||||
@@ -436,7 +438,8 @@ func assertDisabledCacheWritesAreNoOps(
|
|||||||
t.Fatalf("PutVariant on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("PutVariant on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := cache.PutSource(ctx, req.Source, bytes.NewReader([]byte("data")))
|
key, err := cache.PutSource(ctx, req.Source, imagerequest.FormatJPEG,
|
||||||
|
bytes.NewReader([]byte("data")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PutSource on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("PutSource on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -455,7 +458,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,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)
|
||||||
|
|||||||
@@ -85,7 +85,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)
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ func TestPutSource_FailedRowWriteLeavesNoFile(t *testing.T) {
|
|||||||
req := &imagerequest.Request{
|
req := &imagerequest.Request{
|
||||||
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
|
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
|
||||||
}
|
}
|
||||||
key, err := cache.PutSource(t.Context(), req.Source,
|
key, err := cache.PutSource(t.Context(), req.Source, imagerequest.FormatJPEG,
|
||||||
bytes.NewReader([]byte("source bytes")))
|
bytes.NewReader([]byte("source bytes")))
|
||||||
t.Logf("PutSource() = %q, error = %v", key, err)
|
t.Logf("PutSource() = %q, error = %v", key, err)
|
||||||
|
|
||||||
@@ -135,3 +135,37 @@ 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_ReturnsTheFormatPutSourceStored stores a source as PNG, then
|
||||||
|
// the same source URL again as WebP. After each store, Source must return
|
||||||
|
// the format that store gave.
|
||||||
|
func TestSource_ReturnsTheFormatPutSourceStored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
src := imagerequest.Source{Host: testHostCDN, Path: testPathCat}
|
||||||
|
|
||||||
|
for _, format := range []imagerequest.Format{
|
||||||
|
imagerequest.FormatPNG, imagerequest.FormatWebP,
|
||||||
|
} {
|
||||||
|
_, err := cache.PutSource(t.Context(), src, format,
|
||||||
|
bytes.NewReader([]byte("source bytes")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutSource(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, got, err := cache.Source(t.Context(), src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Source() after PutSource(%s) error = %v", format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
t.Logf("Source() after PutSource(%s): format %s", format, got)
|
||||||
|
|
||||||
|
if got != format {
|
||||||
|
t.Errorf("Source() after PutSource(%s) format = %q, want %q",
|
||||||
|
format, got, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -36,37 +34,22 @@ func TestDetectFormat(t *testing.T) {
|
|||||||
avis := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73)
|
avis := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73)
|
||||||
|
|
||||||
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>`),
|
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
wantMIME: MIMETypeSVG,
|
wantErr: ErrUnknownFormat,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SVG without declaration",
|
|
||||||
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
|
||||||
wantMIME: MIMETypeSVG,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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