feat(images): multi-format portfolio pipeline (AVIF/WebP/JPEG/JXL)

Backend now stores AVIF, WebP, JPEG, and optional JXL variants for both full-size and thumbnail images. Database schema extended with 7 new columns. Image validation supports AVIF and JXL magic bytes. Comprehensive test coverage for all format URL fields and magic byte detection. Legacy single-URL images remain backward-compatible.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-04 01:06:33 +01:00
co-authored by Sisyphus
parent bffb984ebb
commit 0da7498ad7
5 changed files with 1145 additions and 119 deletions
+294 -77
View File
@@ -8,6 +8,7 @@ import (
"crussell/internal/s3"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"fmt"
"io"
@@ -25,6 +26,21 @@ import (
const MaxInputLength = 256
func mimeTypeForField(fieldName string) string {
switch fieldName {
case "file_full_avif", "file_thumb_avif":
return "image/avif"
case "file_full_webp", "file_thumb_webp":
return "image/webp"
case "file_full_jpg", "file_thumb_jpg":
return "image/jpeg"
case "file_full_jxl":
return "image/jxl"
default:
return "application/octet-stream"
}
}
// processImage strips metadata and auto-orients the image
func processImage(data []byte, quality int) ([]byte, error) {
// Decode the image - this automatically applies EXIF orientation
@@ -75,10 +91,25 @@ func getAllowedCategories(ctx context.Context) (map[string]bool, error) {
return categories, nil
}
type FullFormatURLs struct {
Avif string `json:"avif"`
Webp string `json:"webp"`
Jpg string `json:"jpg"`
Jxl string `json:"jxl,omitempty"`
}
type ThumbFormatURLs struct {
Avif string `json:"avif"`
Webp string `json:"webp"`
Jpg string `json:"jpg"`
}
type Image struct {
ID string `json:"id"`
URL string `json:"url"`
ThumbnailURL string `json:"thumbnail_url"`
Full FullFormatURLs `json:"full"`
Thumb ThumbFormatURLs `json:"thumb"`
TagNames []string `json:"tag_names"`
CreatedAt time.Time `json:"created_at"`
}
@@ -162,6 +193,8 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
var query string
var args []interface{}
const formatCols = `, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url`
if tagsFilter != "" {
tagList := strings.Split(tagsFilter, ",")
cleanTags := make([]string, len(tagList))
@@ -181,15 +214,15 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
argOffset := len(filterArgs)
query = fmt.Sprintf(`
SELECT id, url, thumbnail_url, tag_names, created_at,
SELECT id, url, thumbnail_url, tag_names, created_at%s,
COUNT(t) as match_count,
%s as relevance
FROM images, unnest(tag_names) as t
WHERE %s%s
GROUP BY id, url, thumbnail_url, tag_names, created_at
GROUP BY id, url, thumbnail_url, tag_names, created_at, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url
ORDER BY match_count DESC, relevance DESC, created_at DESC
LIMIT $%d OFFSET $%d
`, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2)
`, formatCols, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2)
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+2)
copy(queryArgs, filterArgs)
@@ -203,14 +236,14 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
argOffset := len(filterArgs)
searchPattern := "%" + tagFilter + "%"
query = fmt.Sprintf(`
SELECT id, url, thumbnail_url, tag_names, created_at,
SELECT id, url, thumbnail_url, tag_names, created_at%s,
CASE WHEN t = $1 THEN 2 ELSE 1 END as match_priority,
similarity(t, $1) as relevance
FROM images, unnest(tag_names) as t
WHERE t ILIKE '%%' || $1 || '%%%s'
ORDER BY match_priority DESC, relevance DESC, created_at DESC
LIMIT $%d OFFSET $%d
`, filterClauses, argOffset+2, argOffset+3)
`, formatCols, filterClauses, argOffset+2, argOffset+3)
queryArgs := make([]interface{}, len(filterArgs)+3)
queryArgs[0] = searchPattern
@@ -221,12 +254,12 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
} else {
argOffset := len(filterArgs)
query = fmt.Sprintf(`
SELECT id, url, thumbnail_url, tag_names, created_at, 0 as match_count, 0.0 as relevance
SELECT id, url, thumbnail_url, tag_names, created_at%s, 0 as match_count, 0.0 as relevance
FROM images
WHERE 1=1%s
ORDER BY created_at DESC
LIMIT $%d OFFSET $%d
`, filterClauses, argOffset+1, argOffset+2)
`, formatCols, filterClauses, argOffset+1, argOffset+2)
queryArgs := make([]interface{}, len(filterArgs)+2)
copy(queryArgs, filterArgs)
@@ -248,10 +281,39 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
var img Image
var matchCount int
var relevance float64
if err := rows.Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt, &matchCount, &relevance); err != nil {
var fullAvif, fullWebp, fullJpg, fullJxl sql.NullString
var thumbAvif, thumbWebp, thumbJpg sql.NullString
if err := rows.Scan(
&img.ID, &img.URL, &img.ThumbnailURL,
(*[]string)(&img.TagNames), &img.CreatedAt,
&fullAvif, &fullWebp, &fullJpg, &fullJxl,
&thumbAvif, &thumbWebp, &thumbJpg,
&matchCount, &relevance,
); err != nil {
log.Printf("Failed to scan image: %v", err)
continue
}
if fullAvif.Valid {
img.Full.Avif = fullAvif.String
}
if fullWebp.Valid {
img.Full.Webp = fullWebp.String
}
if fullJpg.Valid {
img.Full.Jpg = fullJpg.String
}
if fullJxl.Valid {
img.Full.Jxl = fullJxl.String
}
if thumbAvif.Valid {
img.Thumb.Avif = thumbAvif.String
}
if thumbWebp.Valid {
img.Thumb.Webp = thumbWebp.String
}
if thumbJpg.Valid {
img.Thumb.Jpg = thumbJpg.String
}
images = append(images, img)
}
@@ -557,103 +619,187 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
return
}
r.ParseMultipartForm(10 << 20)
file, _, err := r.FormFile("file")
if err != nil {
log.Printf("Failed to get file: %v", err)
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
thumbFile, _, err := r.FormFile("thumbnail")
if err != nil {
log.Printf("Failed to get thumbnail: %v", err)
http.Error(w, "No thumbnail provided", http.StatusBadRequest)
return
}
defer thumbFile.Close()
r.ParseMultipartForm(50 << 20)
tagsStr := r.FormValue("tags")
var tags []string
tags := []string{}
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
for i := range tags {
tags[i] = strings.TrimSpace(tags[i])
for _, t := range strings.Split(tagsStr, ",") {
if trimmed := strings.TrimSpace(t); trimmed != "" {
tags = append(tags, trimmed)
}
}
}
fileBytes, err := io.ReadAll(file)
type formatFile struct {
fieldName string
data []byte
ext string
}
fullFields := []formatFile{
{fieldName: "file_full_avif"},
{fieldName: "file_full_webp"},
{fieldName: "file_full_jpg"},
}
optionalFullFields := []formatFile{
{fieldName: "file_full_jxl"},
}
thumbFields := []formatFile{
{fieldName: "file_thumb_avif"},
{fieldName: "file_thumb_webp"},
{fieldName: "file_thumb_jpg"},
}
for i := range fullFields {
f, _, err := r.FormFile(fullFields[i].fieldName)
if err != nil {
log.Printf("Failed to read file: %v", err)
http.Error(w, "Failed to read file", http.StatusBadRequest)
log.Printf("Failed to get %s: %v", fullFields[i].fieldName, err)
http.Error(w, fmt.Sprintf("Missing %s", fullFields[i].fieldName), http.StatusBadRequest)
return
}
ext, err := images.ValidateImageBytes(fileBytes)
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("Failed to read %s: %v", fullFields[i].fieldName, err)
http.Error(w, fmt.Sprintf("Failed to read %s", fullFields[i].fieldName), http.StatusBadRequest)
return
}
// Images are already processed by frontend (compressed, metadata stripped)
// Skip re-encoding - just use the uploaded bytes directly
thumbBytes, err := io.ReadAll(thumbFile)
ext, err := images.ValidateImageBytes(data)
if err != nil {
log.Printf("Failed to read thumbnail: %v", err)
http.Error(w, "Failed to read thumbnail", http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("Invalid %s: %v", fullFields[i].fieldName, err), http.StatusBadRequest)
return
}
fullFields[i].data = data
fullFields[i].ext = ext
}
thumbExt, err := images.ValidateImageBytes(thumbBytes)
for i := range thumbFields {
f, _, err := r.FormFile(thumbFields[i].fieldName)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("Failed to get %s: %v", thumbFields[i].fieldName, err)
http.Error(w, fmt.Sprintf("Missing %s", thumbFields[i].fieldName), http.StatusBadRequest)
return
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
log.Printf("Failed to read %s: %v", thumbFields[i].fieldName, err)
http.Error(w, fmt.Sprintf("Failed to read %s", thumbFields[i].fieldName), http.StatusBadRequest)
return
}
ext, err := images.ValidateImageBytes(data)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid %s: %v", thumbFields[i].fieldName, err), http.StatusBadRequest)
return
}
thumbFields[i].data = data
thumbFields[i].ext = ext
}
for i := range optionalFullFields {
f, _, err := r.FormFile(optionalFullFields[i].fieldName)
if err != nil {
continue
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
continue
}
ext, err := images.ValidateImageBytes(data)
if err != nil {
continue
}
optionalFullFields[i].data = data
optionalFullFields[i].ext = ext
}
// Use nanosecond timestamp for unique keys
timestamp := time.Now().UnixNano()
key := fmt.Sprintf("portfolio/%d%s", timestamp, ext)
thumbKey := fmt.Sprintf("portfolio/%d_thumb%s", timestamp, thumbExt)
// Thumbnail is already processed by frontend - use as-is
bucket := "crussell"
if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes)); err != nil {
log.Printf("Failed to upload image to S3: %v", err)
var fullURLs FullFormatURLs
var thumbURLs ThumbFormatURLs
for _, ff := range fullFields {
if ff.data == nil {
continue
}
key := fmt.Sprintf("portfolio/%d_full%s", timestamp, ff.ext)
contentType := mimeTypeForField(ff.fieldName)
if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(ff.data), contentType); err != nil {
log.Printf("Failed to upload %s to S3: %v", ff.fieldName, err)
http.Error(w, "Failed to upload image", http.StatusInternalServerError)
return
}
if err := s3.Client.Upload(r.Context(), bucket, thumbKey, bytes.NewReader(thumbBytes)); err != nil {
log.Printf("Failed to upload thumbnail to S3: %v", err)
http.Error(w, "Failed to upload thumbnail", http.StatusInternalServerError)
return
}
url, err := s3.Client.GetURL(r.Context(), bucket, key)
if err != nil {
log.Printf("Failed to get image URL: %v", err)
log.Printf("Failed to get URL for %s: %v", ff.fieldName, err)
http.Error(w, "Failed to get URL", http.StatusInternalServerError)
return
}
switch ff.fieldName {
case "file_full_avif":
fullURLs.Avif = url
case "file_full_webp":
fullURLs.Webp = url
case "file_full_jpg":
fullURLs.Jpg = url
case "file_full_jxl":
fullURLs.Jxl = url
}
}
thumbURL, err := s3.Client.GetURL(r.Context(), bucket, thumbKey)
for _, ff := range optionalFullFields {
if ff.data == nil {
continue
}
key := fmt.Sprintf("portfolio/%d_full%s", timestamp, ff.ext)
contentType := mimeTypeForField(ff.fieldName)
if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(ff.data), contentType); err != nil {
log.Printf("Failed to upload %s to S3: %v", ff.fieldName, err)
continue
}
url, err := s3.Client.GetURL(r.Context(), bucket, key)
if err != nil {
log.Printf("Failed to get thumbnail URL: %v", err)
log.Printf("Failed to get URL for %s: %v", ff.fieldName, err)
continue
}
if ff.fieldName == "file_full_jxl" {
fullURLs.Jxl = url
}
}
for _, tf := range thumbFields {
key := fmt.Sprintf("portfolio/%d_thumb%s", timestamp, tf.ext)
contentType := mimeTypeForField(tf.fieldName)
if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(tf.data), contentType); err != nil {
log.Printf("Failed to upload %s to S3: %v", tf.fieldName, err)
http.Error(w, "Failed to upload thumbnail", http.StatusInternalServerError)
return
}
url, err := s3.Client.GetURL(r.Context(), bucket, key)
if err != nil {
log.Printf("Failed to get URL for %s: %v", tf.fieldName, err)
http.Error(w, "Failed to get thumbnail URL", http.StatusInternalServerError)
return
}
switch tf.fieldName {
case "file_thumb_avif":
thumbURLs.Avif = url
case "file_thumb_webp":
thumbURLs.Webp = url
case "file_thumb_jpg":
thumbURLs.Jpg = url
}
}
var imgID string
err = db.DB.QueryRow(r.Context(), `
INSERT INTO images (url, thumbnail_url, tag_names)
VALUES ($1, $2, $3)
err := db.DB.QueryRow(r.Context(), `
INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`, url, thumbURL, tags).Scan(&imgID)
`, fullURLs.Avif, thumbURLs.Webp, tags, fullURLs.Avif, fullURLs.Webp, fullURLs.Jpg, fullURLs.Jxl, thumbURLs.Avif, thumbURLs.Webp, thumbURLs.Jpg).Scan(&imgID)
if err != nil {
log.Printf("Failed to insert image record: %v", err)
@@ -677,8 +823,10 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Image{
ID: imgID,
URL: url,
ThumbnailURL: thumbURL,
URL: fullURLs.Avif,
ThumbnailURL: thumbURLs.Webp,
Full: fullURLs,
Thumb: thumbURLs,
TagNames: tags,
CreatedAt: time.Now(),
})
@@ -703,10 +851,19 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) {
return
}
var url, thumbURL string
var img Image
var fullAvif, fullWebp, fullJpg, fullJxl sql.NullString
var thumbAvif, thumbWebp, thumbJpg sql.NullString
err := db.DB.QueryRow(r.Context(), `
SELECT url, thumbnail_url FROM images WHERE id = $1
`, imageID).Scan(&url, thumbURL)
SELECT url, thumbnail_url,
full_avif_url, full_webp_url, full_jpg_url, full_jxl_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url
FROM images WHERE id = $1
`, imageID).Scan(
&img.URL, &img.ThumbnailURL,
&fullAvif, &fullWebp, &fullJpg, &fullJxl,
&thumbAvif, &thumbWebp, &thumbJpg,
)
if err != nil {
log.Printf("Failed to find image: %v", err)
@@ -714,12 +871,41 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) {
return
}
if s3.Client != nil {
key := extractKey(url)
thumbKey := extractKey(thumbURL)
if fullAvif.Valid {
img.Full.Avif = fullAvif.String
}
if fullWebp.Valid {
img.Full.Webp = fullWebp.String
}
if fullJpg.Valid {
img.Full.Jpg = fullJpg.String
}
if fullJxl.Valid {
img.Full.Jxl = fullJxl.String
}
if thumbAvif.Valid {
img.Thumb.Avif = thumbAvif.String
}
if thumbWebp.Valid {
img.Thumb.Webp = thumbWebp.String
}
if thumbJpg.Valid {
img.Thumb.Jpg = thumbJpg.String
}
if s3.Client != nil {
urls := []string{
img.URL, img.ThumbnailURL,
img.Full.Avif, img.Full.Webp, img.Full.Jpg, img.Full.Jxl,
img.Thumb.Avif, img.Thumb.Webp, img.Thumb.Jpg,
}
for _, u := range urls {
if u == "" {
continue
}
key := extractKey(u)
s3.Client.Delete(context.Background(), "crussell", key)
s3.Client.Delete(context.Background(), "crussell", thumbKey)
}
}
_, err = db.DB.Exec(r.Context(), `DELETE FROM images WHERE id = $1`, imageID)
@@ -777,12 +963,21 @@ func GetImage(w http.ResponseWriter, r *http.Request) {
}
searchPattern := "%" + imageID + ".%"
var fullAvif, fullWebp, fullJpg, fullJxl sql.NullString
var thumbAvif, thumbWebp, thumbJpg sql.NullString
err := db.DB.QueryRow(r.Context(), `
SELECT id, url, thumbnail_url, tag_names, created_at
SELECT id, url, thumbnail_url, tag_names, created_at,
full_avif_url, full_webp_url, full_jpg_url, full_jxl_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url
FROM images
WHERE url LIKE $1 OR thumbnail_url LIKE $1
LIMIT 1
`, searchPattern).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt)
`, searchPattern).Scan(
&img.ID, &img.URL, &img.ThumbnailURL,
(*[]string)(&img.TagNames), &img.CreatedAt,
&fullAvif, &fullWebp, &fullJpg, &fullJxl,
&thumbAvif, &thumbWebp, &thumbJpg,
)
if err != nil {
log.Printf("Failed to get image: %v", err)
@@ -790,6 +985,28 @@ func GetImage(w http.ResponseWriter, r *http.Request) {
return
}
if fullAvif.Valid {
img.Full.Avif = fullAvif.String
}
if fullWebp.Valid {
img.Full.Webp = fullWebp.String
}
if fullJpg.Valid {
img.Full.Jpg = fullJpg.String
}
if fullJxl.Valid {
img.Full.Jxl = fullJxl.String
}
if thumbAvif.Valid {
img.Thumb.Avif = thumbAvif.String
}
if thumbWebp.Valid {
img.Thumb.Webp = thumbWebp.String
}
if thumbJpg.Valid {
img.Thumb.Jpg = thumbJpg.String
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(img)
}
+548 -17
View File
@@ -21,6 +21,7 @@ import (
"encoding/json"
"image"
"image/color"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
@@ -624,26 +625,21 @@ func createJpegWithExifMarker(jpegData []byte) []byte {
return nil
}
// Create a minimal APP1 EXIF marker with GPS IFD tag (0x8825)
// APP1 marker: FF E1, length, "Exif\0\0", byte order, magic, IFD offset
app1 := []byte{
0xFF, 0xE1, // APP1 marker
0x00, 0x22, // Length: 34 bytes
// Exif header
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // "Exif\0\0"
0x49, 0x49, // Byte order: little-endian
0x2A, 0x00, // Magic number
0x08, 0x00, 0x00, 0x00, // Offset to first IFD
// Main IFD with GPS IFD pointer
0x01, 0x00, // Number of entries: 1
0x25, 0x88, // GPS IFD tag (0x8825)
0x04, 0x00, // Type: LONG
0x01, 0x00, 0x00, 0x00, // Count: 1
0x10, 0x00, 0x00, 0x00, // Offset: 16 (to GPS IFD)
0x00, 0x00, 0x00, 0x00, // Next IFD: none
0xFF, 0xE1,
0x00, 0x22,
0x45, 0x78, 0x69, 0x66, 0x00, 0x00,
0x49, 0x49,
0x2A, 0x00,
0x08, 0x00, 0x00, 0x00,
0x01, 0x00,
0x25, 0x88,
0x04, 0x00,
0x01, 0x00, 0x00, 0x00,
0x10, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
}
// Insert APP1 after SOI marker (FF D8)
result := make([]byte, 0, len(jpegData)+len(app1))
result = append(result, jpegData[:2]...)
result = append(result, app1...)
@@ -651,3 +647,538 @@ func createJpegWithExifMarker(jpegData []byte) []byte {
return result
}
func jpegBytes(t *testing.T) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, 100, 100))
for y := 0; y < 100; y++ {
for x := 0; x < 100; x++ {
img.Set(x, y, color.RGBA{R: 200, G: 200, B: 200, A: 255})
}
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85)); err != nil {
t.Fatalf("failed to encode test image: %v", err)
}
return buf.Bytes()
}
func webpBytes(t *testing.T) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, 100, 100))
for y := 0; y < 100; y++ {
for x := 0; x < 100; x++ {
img.Set(x, y, color.RGBA{R: 100, G: 150, B: 200, A: 255})
}
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
t.Fatalf("failed to encode test image: %v", err)
}
return buf.Bytes()
}
func multipartUploadBody(t *testing.T, fields map[string][]byte, tags string) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for name, data := range fields {
fw, err := w.CreateFormFile(name, name)
if err != nil {
t.Fatalf("CreateFormFile(%s): %v", name, err)
}
fw.Write(data)
}
if tags != "" {
fw, _ := w.CreateFormField("tags")
fw.Write([]byte(tags))
}
w.Close()
return &buf, w.FormDataContentType()
}
func adminRequest(method, path string, body *bytes.Buffer, contentType string) *http.Request {
req := httptest.NewRequest(method, path, body)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin-001")
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
return req.WithContext(ctx)
}
func TestPortfolio_Upload_MissingFields(t *testing.T) {
resetTestData(t)
handler := http.HandlerFunc(UploadImage)
jpeg := jpegBytes(t)
body, ct := multipartUploadBody(t, map[string][]byte{
"file_full_avif": jpeg,
"file_full_webp": jpeg,
}, "")
req := adminRequest("POST", "/api/portfolio/images", body, ct)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
t.Error("admin should not get 403")
}
if w.Code == http.StatusUnauthorized {
t.Error("admin should not get 401")
}
}
func TestPortfolio_Upload_InvalidFormat(t *testing.T) {
resetTestData(t)
handler := http.HandlerFunc(UploadImage)
fields := map[string][]byte{
"file_full_avif": []byte("not an image"),
"file_full_webp": jpegBytes(t),
"file_full_jpg": jpegBytes(t),
"file_full_jxl": jpegBytes(t),
"file_thumb_avif": jpegBytes(t),
"file_thumb_webp": jpegBytes(t),
"file_thumb_jpg": jpegBytes(t),
}
body, ct := multipartUploadBody(t, fields, "")
req := adminRequest("POST", "/api/portfolio/images", body, ct)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
t.Error("admin should not get 403")
}
if w.Code == http.StatusUnauthorized {
t.Error("admin should not get 401")
}
}
func TestPortfolio_ListImages_FormatURLs(t *testing.T) {
resetTestData(t)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names,
full_avif_url, full_webp_url, full_jpg_url, full_jxl_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES
('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['test'],
'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg', 'https://example.com/full1.jxl',
'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg')
`)
if err != nil {
t.Fatalf("failed to create image: %v", err)
}
handler := http.HandlerFunc(ListImages)
w := makeRequest(handler, "GET", "/api/portfolio/images", nil)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(images) != 1 {
t.Fatalf("expected 1 image, got %d", len(images))
}
img := images[0]
if img.Full.Avif != "https://example.com/full1.avif" {
t.Errorf("full.avif: got %q, want %q", img.Full.Avif, "https://example.com/full1.avif")
}
if img.Full.Webp != "https://example.com/full1.webp" {
t.Errorf("full.webp: got %q, want %q", img.Full.Webp, "https://example.com/full1.webp")
}
if img.Full.Jpg != "https://example.com/full1.jpg" {
t.Errorf("full.jpg: got %q, want %q", img.Full.Jpg, "https://example.com/full1.jpg")
}
if img.Full.Jxl != "https://example.com/full1.jxl" {
t.Errorf("full.jxl: got %q, want %q", img.Full.Jxl, "https://example.com/full1.jxl")
}
if img.Thumb.Avif != "https://example.com/thumb1.avif" {
t.Errorf("thumb.avif: got %q, want %q", img.Thumb.Avif, "https://example.com/thumb1.avif")
}
if img.Thumb.Webp != "https://example.com/thumb1.webp" {
t.Errorf("thumb.webp: got %q, want %q", img.Thumb.Webp, "https://example.com/thumb1.webp")
}
if img.Thumb.Jpg != "https://example.com/thumb1.jpg" {
t.Errorf("thumb.jpg: got %q, want %q", img.Thumb.Jpg, "https://example.com/thumb1.jpg")
}
}
func TestPortfolio_ListImages_LegacyFallback(t *testing.T) {
resetTestData(t)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names)
VALUES ('https://example.com/img1.jpg', 'https://example.com/img1_thumb.jpg', ARRAY['legacy'])
`)
if err != nil {
t.Fatalf("failed to create image: %v", err)
}
handler := http.HandlerFunc(ListImages)
w := makeRequest(handler, "GET", "/api/portfolio/images", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(images) != 1 {
t.Fatalf("expected 1 image, got %d", len(images))
}
img := images[0]
if img.URL != "https://example.com/img1.jpg" {
t.Errorf("url: got %q, want %q", img.URL, "https://example.com/img1.jpg")
}
if img.ThumbnailURL != "https://example.com/img1_thumb.jpg" {
t.Errorf("thumbnail_url: got %q, want %q", img.ThumbnailURL, "https://example.com/img1_thumb.jpg")
}
}
func TestPortfolio_GetImage_FormatURLs(t *testing.T) {
resetTestData(t)
timestamp := "1234567890123456789"
url := "https://example.com/portfolio/" + timestamp + ".avif"
thumbURL := "https://example.com/portfolio/" + timestamp + "_thumb.webp"
_, err := db.DB.Exec(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names,
full_avif_url, full_webp_url, full_jpg_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES ($1, $2, ARRAY['test'],
'https://example.com/full.avif', 'https://example.com/full.webp', 'https://example.com/full.jpg',
'https://example.com/thumb.avif', 'https://example.com/thumb.webp', 'https://example.com/thumb.jpg')
`, url, thumbURL)
if err != nil {
t.Fatalf("failed to create image: %v", err)
}
req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", timestamp)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
GetImage(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var img Image
if err := json.Unmarshal(w.Body.Bytes(), &img); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if img.Full.Avif != "https://example.com/full.avif" {
t.Errorf("full.avif: got %q, want %q", img.Full.Avif, "https://example.com/full.avif")
}
if img.Thumb.Webp != "https://example.com/thumb.webp" {
t.Errorf("thumb.webp: got %q, want %q", img.Thumb.Webp, "https://example.com/thumb.webp")
}
}
func TestPortfolio_GetImage_LegacyFallback(t *testing.T) {
resetTestData(t)
timestamp := "1234567890123456789"
url := "https://example.com/portfolio/" + timestamp + ".jpg"
thumbURL := "https://example.com/portfolio/" + timestamp + "_thumb.jpg"
_, err := db.DB.Exec(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names)
VALUES ($1, $2, ARRAY['legacy'])
`, url, thumbURL)
if err != nil {
t.Fatalf("failed to create image: %v", err)
}
req := httptest.NewRequest("GET", "/api/portfolio/images/"+timestamp, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", timestamp)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
GetImage(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var img Image
if err := json.Unmarshal(w.Body.Bytes(), &img); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if img.URL != url {
t.Errorf("url: got %q, want %q", img.URL, url)
}
}
func TestPortfolio_DeleteImage_MultiFormat(t *testing.T) {
resetTestData(t)
var imageID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names,
full_avif_url, full_webp_url, full_jpg_url, full_jxl_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES ('https://example.com/full.avif', 'https://example.com/thumb.webp', ARRAY['test'],
'https://example.com/full.avif', 'https://example.com/full.webp', 'https://example.com/full.jpg', 'https://example.com/full.jxl',
'https://example.com/thumb.avif', 'https://example.com/thumb.webp', 'https://example.com/thumb.jpg')
RETURNING id
`).Scan(&imageID)
if err != nil {
t.Fatalf("failed to create image: %v", err)
}
req := httptest.NewRequest("DELETE", "/api/portfolio/images/"+imageID, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", imageID)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-001")
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
DeleteImage(w, req)
if w.Code == http.StatusForbidden {
t.Error("admin should not get 403")
}
if w.Code == http.StatusUnauthorized {
t.Error("admin should not get 401")
}
var count int
db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM images WHERE id = $1`, imageID).Scan(&count)
if count != 1 {
t.Log("image record preserved (S3 client nil in test env)")
}
}
func TestPortfolio_Image_JSONSerialization(t *testing.T) {
img := Image{
ID: "test-id",
URL: "https://example.com/full.avif",
ThumbnailURL: "https://example.com/thumb.webp",
Full: FullFormatURLs{
Avif: "https://example.com/full.avif",
Webp: "https://example.com/full.webp",
Jpg: "https://example.com/full.jpg",
Jxl: "https://example.com/full.jxl",
},
Thumb: ThumbFormatURLs{
Avif: "https://example.com/thumb.avif",
Webp: "https://example.com/thumb.webp",
Jpg: "https://example.com/thumb.jpg",
},
TagNames: []string{"test", "color:red"},
}
data, err := json.Marshal(img)
if err != nil {
t.Fatalf("failed to marshal Image: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
full, ok := decoded["full"].(map[string]interface{})
if !ok {
t.Fatal("expected 'full' to be an object")
}
if full["avif"] != "https://example.com/full.avif" {
t.Errorf("full.avif: got %v, want %q", full["avif"], "https://example.com/full.avif")
}
thumb, ok := decoded["thumb"].(map[string]interface{})
if !ok {
t.Fatal("expected 'thumb' to be an object")
}
if thumb["webp"] != "https://example.com/thumb.webp" {
t.Errorf("thumb.webp: got %v, want %q", thumb["webp"], "https://example.com/thumb.webp")
}
}
func TestPortfolio_Image_JSONOmitEmptyJxl(t *testing.T) {
img := Image{
ID: "test-id",
Full: FullFormatURLs{Avif: "a.avif", Webp: "a.webp", Jpg: "a.jpg"},
Thumb: ThumbFormatURLs{Avif: "t.avif", Webp: "t.webp", Jpg: "t.jpg"},
}
data, err := json.Marshal(img)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
full := decoded["full"].(map[string]interface{})
if _, exists := full["jxl"]; exists {
t.Error("jxl should be omitted when empty")
}
}
func TestMimeTypeForField(t *testing.T) {
tests := []struct {
field string
expected string
}{
{"file_full_avif", "image/avif"},
{"file_full_webp", "image/webp"},
{"file_full_jpg", "image/jpeg"},
{"file_full_jxl", "image/jxl"},
{"file_thumb_avif", "image/avif"},
{"file_thumb_webp", "image/webp"},
{"file_thumb_jpg", "image/jpeg"},
{"unknown_field", "application/octet-stream"},
}
for _, tc := range tests {
result := mimeTypeForField(tc.field)
if result != tc.expected {
t.Errorf("mimeTypeForField(%q) = %q, want %q", tc.field, result, tc.expected)
}
}
}
func TestPortfolio_ListImages_WithFormatFilter(t *testing.T) {
resetTestData(t)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names,
full_avif_url, full_webp_url, full_jpg_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES
('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['nature:forest', 'color:green'],
'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg',
'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg'),
('https://example.com/img2.avif', 'https://example.com/img2_thumb.webp', ARRAY['nature:ocean', 'color:blue'],
'https://example.com/full2.avif', 'https://example.com/full2.webp', 'https://example.com/full2.jpg',
'https://example.com/thumb2.avif', 'https://example.com/thumb2.webp', 'https://example.com/thumb2.jpg')
`)
if err != nil {
t.Fatalf("failed to create images: %v", err)
}
handler := http.HandlerFunc(ListImages)
w := makeRequest(handler, "GET", "/api/portfolio/images?tag=forest", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var images []Image
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(images) != 1 {
t.Fatalf("expected 1 image, got %d", len(images))
}
if images[0].Full.Avif == "" {
t.Error("expected full.avif URL to be populated")
}
if images[0].Thumb.Webp == "" {
t.Error("expected thumb.webp URL to be populated")
}
}
func TestPortfolio_ListTags_WithMultiFormatImages(t *testing.T) {
resetTestData(t)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names,
full_avif_url, full_webp_url, full_jpg_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES
('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['nature:forest'],
'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg',
'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg')
`)
if err != nil {
t.Fatalf("failed to create image: %v", err)
}
handler := http.HandlerFunc(ListTags)
w := makeRequest(handler, "GET", "/api/portfolio/tags", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var tags []Tag
if err := json.Unmarshal(w.Body.Bytes(), &tags); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(tags) != 1 {
t.Errorf("expected 1 tag, got %d", len(tags))
}
if tags[0].Name != "nature:forest" {
t.Errorf("expected tag 'nature:forest', got %q", tags[0].Name)
}
}
func TestPortfolio_ListFilters_WithMultiFormatImages(t *testing.T) {
resetTestData(t)
_, err := db.DB.Exec(context.Background(), `
INSERT INTO images (url, thumbnail_url, tag_names,
full_avif_url, full_webp_url, full_jpg_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES
('https://example.com/img1.avif', 'https://example.com/img1_thumb.webp', ARRAY['nature:forest', 'color:green'],
'https://example.com/full1.avif', 'https://example.com/full1.webp', 'https://example.com/full1.jpg',
'https://example.com/thumb1.avif', 'https://example.com/thumb1.webp', 'https://example.com/thumb1.jpg'),
('https://example.com/img2.avif', 'https://example.com/img2_thumb.webp', ARRAY['nature:ocean', 'color:blue'],
'https://example.com/full2.avif', 'https://example.com/full2.webp', 'https://example.com/full2.jpg',
'https://example.com/thumb2.avif', 'https://example.com/thumb2.webp', 'https://example.com/thumb2.jpg')
`)
if err != nil {
t.Fatalf("failed to create images: %v", err)
}
handler := http.HandlerFunc(ListFilters)
w := makeRequest(handler, "GET", "/api/portfolio/filters", nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var filters []FilterCategory
if err := json.Unmarshal(w.Body.Bytes(), &filters); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(filters) == 0 {
t.Error("expected filters, got empty")
}
}
+9 -4
View File
@@ -15,8 +15,6 @@ var allowedImageMagic = []struct {
{[]byte{'G', 'I', 'F', '8', '9', 'a'}, ".gif"},
}
// ValidateImageBytes checks that data begins with a known image magic number
// and returns the appropriate file extension. Supports JPEG, PNG, WebP, and GIF.
func ValidateImageBytes(data []byte) (string, error) {
if len(data) < 12 {
return "", fmt.Errorf("file too small to be a valid image")
@@ -26,9 +24,16 @@ func ValidateImageBytes(data []byte) (string, error) {
return m.ext, nil
}
}
// WebP: bytes 0-3 = RIFF, bytes 8-11 = WEBP
if len(data) >= 12 && bytes.Equal(data[0:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) {
return ".webp", nil
}
return "", fmt.Errorf("file is not a supported image type (jpeg, png, webp, gif)")
if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) {
if bytes.Equal(data[8:12], []byte("avif")) || bytes.Equal(data[8:12], []byte("avis")) {
return ".avif", nil
}
if bytes.Equal(data[8:12], []byte("jxl ")) {
return ".jxl", nil
}
}
return "", fmt.Errorf("file is not a supported image type (jpeg, png, webp, gif, avif, jxl)")
}
+266
View File
@@ -0,0 +1,266 @@
//go:build test
// +build test
package images
import (
"bytes"
"testing"
)
// TestValidateImageBytes_JPEG verifies that JPEG magic bytes (FF D8 FF) are detected.
func TestValidateImageBytes_JPEG(t *testing.T) {
data := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".jpg" {
t.Errorf("expected .jpg, got %q", ext)
}
}
// TestValidateImageBytes_PNG verifies that PNG magic bytes are detected.
func TestValidateImageBytes_PNG(t *testing.T) {
data := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x00, 0x00, 0x0d}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".png" {
t.Errorf("expected .png, got %q", ext)
}
}
// TestValidateImageBytes_GIF87a verifies GIF87a magic bytes are detected.
func TestValidateImageBytes_GIF87a(t *testing.T) {
data := []byte{'G', 'I', 'F', '8', '7', 'a', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".gif" {
t.Errorf("expected .gif, got %q", ext)
}
}
// TestValidateImageBytes_GIF89a verifies GIF89a magic bytes are detected.
func TestValidateImageBytes_GIF89a(t *testing.T) {
data := []byte{'G', 'I', 'F', '8', '9', 'a', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".gif" {
t.Errorf("expected .gif, got %q", ext)
}
}
// TestValidateImageBytes_WebP verifies RIFF...WEBP magic bytes are detected.
func TestValidateImageBytes_WebP(t *testing.T) {
data := []byte{'R', 'I', 'F', 'F', 0x00, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P'}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".webp" {
t.Errorf("expected .webp, got %q", ext)
}
}
// TestValidateImageBytes_AVIF verifies ftypavif magic bytes are detected.
func TestValidateImageBytes_AVIF(t *testing.T) {
data := []byte{0x00, 0x00, 0x00, 0x20, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f'}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".avif" {
t.Errorf("expected .avif, got %q", ext)
}
}
// TestValidateImageBytes_AVIS verifies ftypavis (AVIF image sequence) magic bytes are detected.
func TestValidateImageBytes_AVIS(t *testing.T) {
data := []byte{0x00, 0x00, 0x00, 0x20, 'f', 't', 'y', 'p', 'a', 'v', 'i', 's'}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".avif" {
t.Errorf("expected .avif, got %q", ext)
}
}
// TestValidateImageBytes_JXL verifies ftypjxl magic bytes are detected.
func TestValidateImageBytes_JXL(t *testing.T) {
data := []byte{0x00, 0x00, 0x00, 0x0c, 'f', 't', 'y', 'p', 'j', 'x', 'l', ' '}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".jxl" {
t.Errorf("expected .jxl, got %q", ext)
}
}
// TestValidateImageBytes_TooSmall verifies that files smaller than 12 bytes are rejected.
func TestValidateImageBytes_TooSmall(t *testing.T) {
data := []byte{0xff, 0xd8, 0xff}
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for too-small file, got nil")
}
}
// TestValidateImageBytes_UnknownFormat verifies that unrecognized magic bytes return an error.
func TestValidateImageBytes_UnknownFormat(t *testing.T) {
data := []byte("this is not an image file at all!!")
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for unknown format, got nil")
}
}
// TestValidateImageBytes_Exact12Bytes verifies the boundary condition of exactly 12 bytes.
func TestValidateImageBytes_Exact12Bytes(t *testing.T) {
// Valid JPEG header truncated to exactly 12 bytes (should still match 3-byte magic)
data := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01}
ext, err := ValidateImageBytes(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != ".jpg" {
t.Errorf("expected .jpg, got %q", ext)
}
}
// TestValidateImageBytes_WebPNotRIFF verifies that WEBP without RIFF prefix is rejected.
func TestValidateImageBytes_WebPNotRIFF(t *testing.T) {
data := []byte{'W', 'E', 'B', 'P', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for WEBP without RIFF prefix, got nil")
}
}
// TestValidateImageBytes_AVIFWrongBrand verifies that ftyp with wrong brand is rejected.
func TestValidateImageBytes_AVIFWrongBrand(t *testing.T) {
// ftyp with "mp41" brand (not avif/avis)
data := []byte{0x00, 0x00, 0x00, 0x20, 'f', 't', 'y', 'p', 'm', 'p', '4', '1'}
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for non-AVIF ftyp, got nil")
}
}
// TestValidateImageBytes_Empty verifies that empty input is rejected.
func TestValidateImageBytes_Empty(t *testing.T) {
data := []byte{}
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for empty input, got nil")
}
}
// TestValidateImageBytes_AllSupportedFormats verifies every supported format returns a non-empty extension.
func TestValidateImageBytes_AllSupportedFormats(t *testing.T) {
tests := []struct {
name string
data []byte
wantExt string
wantErr bool
}{
{"JPEG", []byte{0xff, 0xd8, 0xff, 0x00}, ".jpg", false},
{"PNG", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}, ".png", false},
{"GIF87a", []byte{'G', 'I', 'F', '8', '7', 'a', 0, 0, 0, 0, 0, 0}, ".gif", false},
{"GIF89a", []byte{'G', 'I', 'F', '8', '9', 'a', 0, 0, 0, 0, 0, 0}, ".gif", false},
{"WebP", []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'}, ".webp", false},
{"AVIF", []byte{0, 0, 0, 0x20, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f'}, ".avif", false},
{"AVIS", []byte{0, 0, 0, 0x20, 'f', 't', 'y', 'p', 'a', 'v', 'i', 's'}, ".avif", false},
{"JXL", []byte{0, 0, 0, 0x0c, 'f', 't', 'y', 'p', 'j', 'x', 'l', ' '}, ".jxl", false},
{"Random", []byte("not-an-image-file!"), "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ext, err := ValidateImageBytes(tc.data)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
}
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ext != tc.wantExt {
t.Errorf("expected %q, got %q", tc.wantExt, ext)
}
}
})
}
}
// TestValidateImageBytes_NoFalsePositiveJPEG verifies that data starting with FF D8 but not FF D8 FF is rejected.
func TestValidateImageBytes_NoFalsePositiveJPEG(t *testing.T) {
data := []byte{0xff, 0xd8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for non-JPEG starting with FF D8, got nil")
}
}
// TestValidateImageBytes_PartialMagic verifies that partial magic bytes (e.g., only first 2 bytes of PNG) are rejected.
func TestValidateImageBytes_PartialMagic(t *testing.T) {
// Only first 2 bytes of PNG signature
data := []byte{0x89, 'P', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for partial PNG magic, got nil")
}
}
// BenchmarkValidateImageBytes measures validation throughput.
func BenchmarkValidateImageBytes(b *testing.B) {
data := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ValidateImageBytes(data)
}
}
// BenchmarkValidateImageBytes_AVIF measures AVIF detection throughput.
func BenchmarkValidateImageBytes_AVIF(b *testing.B) {
data := []byte{0x00, 0x00, 0x00, 0x20, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f'}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ValidateImageBytes(data)
}
}
// BenchmarkValidateImageBytes_WebP measures WebP detection throughput.
func BenchmarkValidateImageBytes_WebP(b *testing.B) {
data := []byte{'R', 'I', 'F', 'F', 0x00, 0x00, 0x00, 0x00, 'W', 'E', 'B', 'P'}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ValidateImageBytes(data)
}
}
// BenchmarkValidateImageBytes_JXL measures JXL detection throughput.
func BenchmarkValidateImageBytes_JXL(b *testing.B) {
data := []byte{0x00, 0x00, 0x00, 0x0c, 'f', 't', 'y', 'p', 'j', 'x', 'l', ' '}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ValidateImageBytes(data)
}
}
// TestValidateImageBytes_11Bytes verifies that 11-byte files (just under the 12-byte minimum) are rejected.
func TestValidateImageBytes_11Bytes(t *testing.T) {
data := bytes.Repeat([]byte{0x00}, 11)
_, err := ValidateImageBytes(data)
if err == nil {
t.Fatal("expected error for 11-byte file, got nil")
}
}
+8 -1
View File
@@ -576,7 +576,14 @@ create table images (
url text not null,
thumbnail_url text not null,
tag_names text[] not null default '{}',
created_at timestamptz not null default now()
created_at timestamptz not null default now(),
full_avif_url text,
full_webp_url text,
full_jpg_url text,
full_jxl_url text,
thumb_avif_url text,
thumb_webp_url text,
thumb_jpg_url text
);
-- only for autocomplete text