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:
@@ -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,12 +91,27 @@ 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"`
|
||||
TagNames []string `json:"tag_names"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
@@ -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)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read file: %v", err)
|
||||
http.Error(w, "Failed to read file", http.StatusBadRequest)
|
||||
return
|
||||
type formatFile struct {
|
||||
fieldName string
|
||||
data []byte
|
||||
ext string
|
||||
}
|
||||
|
||||
ext, err := images.ValidateImageBytes(fileBytes)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
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"},
|
||||
}
|
||||
|
||||
// Images are already processed by frontend (compressed, metadata stripped)
|
||||
// Skip re-encoding - just use the uploaded bytes directly
|
||||
|
||||
thumbBytes, err := io.ReadAll(thumbFile)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read thumbnail: %v", err)
|
||||
http.Error(w, "Failed to read thumbnail", http.StatusInternalServerError)
|
||||
return
|
||||
for i := range fullFields {
|
||||
f, _, err := r.FormFile(fullFields[i].fieldName)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get %s: %v", fullFields[i].fieldName, err)
|
||||
http.Error(w, fmt.Sprintf("Missing %s", fullFields[i].fieldName), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
ext, err := images.ValidateImageBytes(data)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
for i := range thumbFields {
|
||||
f, _, err := r.FormFile(thumbFields[i].fieldName)
|
||||
if err != nil {
|
||||
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)
|
||||
http.Error(w, "Failed to upload image", http.StatusInternalServerError)
|
||||
return
|
||||
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
|
||||
}
|
||||
url, err := s3.Client.GetURL(r.Context(), bucket, key)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 URL for %s: %v", ff.fieldName, err)
|
||||
continue
|
||||
}
|
||||
if ff.fieldName == "file_full_jxl" {
|
||||
fullURLs.Jxl = url
|
||||
}
|
||||
}
|
||||
|
||||
url, err := s3.Client.GetURL(r.Context(), bucket, key)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get image URL: %v", err)
|
||||
http.Error(w, "Failed to get URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
thumbURL, err := s3.Client.GetURL(r.Context(), bucket, thumbKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get thumbnail URL: %v", err)
|
||||
http.Error(w, "Failed to get thumbnail URL", http.StatusInternalServerError)
|
||||
return
|
||||
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
|
||||
}
|
||||
|
||||
s3.Client.Delete(context.Background(), "crussell", key)
|
||||
s3.Client.Delete(context.Background(), "crussell", thumbKey)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
_, 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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user