Files
Crussell/backend/handlers/portfolio/images.go
T
popertotsandSisyphus 0da7498ad7 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>
2026-06-04 01:06:33 +01:00

1013 lines
27 KiB
Go

package portfolio
import (
"bytes"
"context"
"crussell/db"
"crussell/internal/images"
"crussell/internal/s3"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kovidgoyal/imaging"
)
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
// and strips all metadata (EXIF, GPS, etc.)
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Encode to JPEG without any metadata
var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(quality))
if err != nil {
return nil, fmt.Errorf("failed to encode image: %w", err)
}
return buf.Bytes(), nil
}
// validateInputLength returns an error if input exceeds max length
func validateInputLength(input string) error {
if len(input) > MaxInputLength {
return fmt.Errorf("input exceeds maximum length of %d characters", MaxInputLength)
}
return nil
}
// getAllowedCategories fetches all unique category prefixes from existing tags
func getAllowedCategories(ctx context.Context) (map[string]bool, error) {
rows, err := db.DB.Query(ctx, `
SELECT DISTINCT SPLIT_PART(t, ':', 1) as category
FROM images, unnest(tag_names) as t
WHERE t LIKE '%:%'
`)
if err != nil {
return nil, err
}
defer rows.Close()
categories := make(map[string]bool)
for rows.Next() {
var cat string
if err := rows.Scan(&cat); err == nil && cat != "" {
categories[cat] = true
}
}
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"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
}
func ListImages(w http.ResponseWriter, r *http.Request) {
tagFilter := r.URL.Query().Get("tag")
tagsFilter := r.URL.Query().Get("tags")
// Validate input length
if tagFilter != "" {
if err := validateInputLength(tagFilter); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if tagsFilter != "" {
if err := validateInputLength(tagsFilter); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
limit := 20
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 && parsed <= 10000 {
offset = parsed
}
}
// Validate filter categories against allowed list from DB
allowedCategories, err := getAllowedCategories(r.Context())
if err != nil {
log.Printf("Failed to get allowed categories: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
filterClauses := ""
filterArgs := []interface{}{}
for key, values := range r.URL.Query() {
if len(values) == 0 || values[0] == "" {
continue
}
match, _ := regexp.Compile(`^filter\[(.+)\]$`)
if match != nil {
matches := match.FindStringSubmatch(key)
if len(matches) == 2 {
category := matches[1]
value := values[0]
// Validate category exists
if !allowedCategories[category] {
continue // Skip invalid categories silently for backward compatibility
}
// Validate input length
if err := validateInputLength(category + ":" + value); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1)
filterArgs = append(filterArgs, category+":"+value)
}
}
}
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))
for i, t := range tagList {
cleanTags[i] = strings.TrimSpace(t)
}
conditions := make([]string, len(cleanTags))
for i := range cleanTags {
conditions[i] = fmt.Sprintf("t ILIKE $%d", len(filterArgs)+i+1)
}
whereClause := strings.Join(conditions, " OR ")
similarityCalls := make([]string, len(cleanTags))
for i := range cleanTags {
similarityCalls[i] = fmt.Sprintf("MAX(similarity(t, $%d))", len(filterArgs)+i+1)
}
similaritySum := strings.Join(similarityCalls, " + ")
argOffset := len(filterArgs)
query = fmt.Sprintf(`
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, 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
`, formatCols, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2)
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+2)
copy(queryArgs, filterArgs)
for i, t := range cleanTags {
queryArgs[len(filterArgs)+i] = t
}
queryArgs[len(filterArgs)+len(cleanTags)] = limit
queryArgs[len(filterArgs)+len(cleanTags)+1] = offset
args = queryArgs
} else if tagFilter != "" {
argOffset := len(filterArgs)
searchPattern := "%" + tagFilter + "%"
query = fmt.Sprintf(`
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
`, formatCols, filterClauses, argOffset+2, argOffset+3)
queryArgs := make([]interface{}, len(filterArgs)+3)
queryArgs[0] = searchPattern
copy(queryArgs[1:], filterArgs)
queryArgs[len(filterArgs)+1] = limit
queryArgs[len(filterArgs)+2] = offset
args = queryArgs
} else {
argOffset := len(filterArgs)
query = fmt.Sprintf(`
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
`, formatCols, filterClauses, argOffset+1, argOffset+2)
queryArgs := make([]interface{}, len(filterArgs)+2)
copy(queryArgs, filterArgs)
queryArgs[len(filterArgs)] = limit
queryArgs[len(filterArgs)+1] = offset
args = queryArgs
}
rows, err := db.DB.Query(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to list images: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var images []Image
for rows.Next() {
var img Image
var matchCount int
var relevance float64
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)
}
if images == nil {
images = []Image{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(images)
}
func ListTags(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if len(q) > 100 {
q = q[:100]
}
var query string
var args []interface{}
// Query tags from images.tag_names column (stored as array)
if q != "" {
query = `
SELECT DISTINCT tag
FROM (
SELECT unnest(tag_names) as tag
FROM images
WHERE tag_names IS NOT NULL
) t
WHERE tag ILIKE '%' || $1 || '%'
ORDER BY tag
LIMIT 20
`
args = []interface{}{q}
} else {
query = `
SELECT DISTINCT tag
FROM (
SELECT unnest(tag_names) as tag
FROM images
WHERE tag_names IS NOT NULL
) t
ORDER BY tag
LIMIT 20
`
}
rows, err := db.DB.Query(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to list tags: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var tags []Tag
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
log.Printf("Failed to scan tag: %v", err)
continue
}
tags = append(tags, Tag{ID: name, Name: name})
}
if tags == nil {
tags = []Tag{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tags)
}
type FilterCategory struct {
Category string `json:"category"`
Values []FilterValue `json:"values"`
}
type FilterValue struct {
Value string `json:"value"`
Count int `json:"count"`
}
func ListFilters(w http.ResponseWriter, r *http.Request) {
tagFilter := r.URL.Query().Get("tag")
tagsFilter := r.URL.Query().Get("tags")
// Get all filter params
selectedCategories := make(map[string]string)
for key, values := range r.URL.Query() {
if strings.HasPrefix(key, "filter[") && len(values) > 0 && values[0] != "" {
category := strings.Trim(key, "[]")
category = strings.TrimPrefix(category, "filter[")
value := values[0]
if len(category)+len(value)+1 > 256 {
http.Error(w, "filter value too long", http.StatusBadRequest)
return
}
selectedCategories[category] = value
}
}
// Build base query
baseQuery := "SELECT DISTINCT id FROM images WHERE 1=1"
args := []interface{}{}
argNum := 1
if tagFilter != "" {
searchPattern := "%" + tagFilter + "%"
baseQuery += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE t ILIKE $%d)", argNum)
args = append(args, searchPattern)
argNum++
}
if tagsFilter != "" {
tagList := strings.Split(tagsFilter, ",")
conditions := make([]string, len(tagList))
for i := range tagList {
tagList[i] = "%" + strings.TrimSpace(tagList[i]) + "%"
conditions[i] = fmt.Sprintf("t ILIKE $%d", argNum)
args = append(args, tagList[i])
argNum++
}
baseQuery += " AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE " + strings.Join(conditions, " OR ") + ")"
}
// Build category filters for OTHER categories
otherFilters := make([]string, 0)
otherArgs := make([]interface{}, len(args))
copy(otherArgs, args)
otherArgNum := argNum
for cat, val := range selectedCategories {
tagValue := cat + ":" + val
otherFilters = append(otherFilters, fmt.Sprintf("AND $%d = ANY(tag_names)", otherArgNum))
otherArgs = append(otherArgs, tagValue)
otherArgNum++
}
// Build the filter count query
filterQuery := `
SELECT
SPLIT_PART(t, ':', 1) as category,
SPLIT_PART(t, ':', 2) as value,
COUNT(*) as count
FROM (` + baseQuery + `) as img_ids
JOIN images ON images.id = img_ids.id
JOIN unnest(images.tag_names) as t ON true
WHERE t LIKE '%:%'
`
// If there are selected categories, we need two queries
if len(selectedCategories) > 0 {
// Query 1: Get all categories with filters applied (for unselected categories)
filterClause := ""
if len(otherFilters) > 0 {
filterClause = " " + strings.Join(otherFilters, " ")
}
filteredQuery := filterQuery + filterClause + " GROUP BY category, value ORDER BY category, count DESC"
rows, err := db.DB.Query(r.Context(), filteredQuery, otherArgs...)
if err != nil {
log.Printf("Failed to list filters: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
results := make(map[string]map[string]int)
for rows.Next() {
var category, value string
var count int
if err := rows.Scan(&category, &value, &count); err != nil {
continue
}
// Skip selected categories in this query
if _, isSelected := selectedCategories[category]; isSelected {
continue
}
if results[category] == nil {
results[category] = make(map[string]int)
}
results[category][value] = count
}
rows.Close()
// Query 2: Get selected categories WITHOUT filters (show all options)
unfilteredQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC"
rows, err = db.DB.Query(r.Context(), unfilteredQuery, args...)
if err != nil {
log.Printf("Failed to list unfiltered filters: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var category, value string
var count int
if err := rows.Scan(&category, &value, &count); err != nil {
continue
}
// Only include selected categories
if _, isSelected := selectedCategories[category]; !isSelected {
continue
}
if results[category] == nil {
results[category] = make(map[string]int)
}
results[category][value] = count
}
// Convert to response
var filters []FilterCategory
for cat, values := range results {
var fv []FilterValue
for val, cnt := range values {
fv = append(fv, FilterValue{Value: val, Count: cnt})
}
sort.Slice(fv, func(i, j int) bool { return fv[i].Count > fv[j].Count })
filters = append(filters, FilterCategory{Category: cat, Values: fv})
}
sort.Slice(filters, func(i, j int) bool {
sumI := 0
for _, v := range filters[i].Values {
sumI += v.Count
}
sumJ := 0
for _, v := range filters[j].Values {
sumJ += v.Count
}
return sumI > sumJ
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(filters)
return
}
// No selected categories - simple query
finalQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC"
rows, err := db.DB.Query(r.Context(), finalQuery, args...)
if err != nil {
log.Printf("Failed to list filters: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var filters []FilterCategory
currentCategory := ""
var currentValues []FilterValue
for rows.Next() {
var category, value string
var count int
if err := rows.Scan(&category, &value, &count); err != nil {
continue
}
if category != currentCategory {
if currentCategory != "" {
filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues})
}
currentCategory = category
currentValues = []FilterValue{}
}
currentValues = append(currentValues, FilterValue{Value: value, Count: count})
}
if currentCategory != "" {
filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues})
}
// Deduplicate categories - use a map to ensure each category appears only once
seenCategories := make(map[string]bool)
var uniqueFilters []FilterCategory
for _, f := range filters {
if !seenCategories[f.Category] {
seenCategories[f.Category] = true
uniqueFilters = append(uniqueFilters, f)
}
}
filters = uniqueFilters
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(filters)
}
func UploadImage(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
adminRole, _ := r.Context().Value(mw.UserRoleKey).(string)
if adminRole != "admin" {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
if s3.Client == nil {
log.Printf("S3 client not initialized")
http.Error(w, "Storage not configured", http.StatusInternalServerError)
return
}
r.ParseMultipartForm(50 << 20)
tagsStr := r.FormValue("tags")
tags := []string{}
if tagsStr != "" {
for _, t := range strings.Split(tagsStr, ",") {
if trimmed := strings.TrimSpace(t); trimmed != "" {
tags = append(tags, trimmed)
}
}
}
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 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
}
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
}
timestamp := time.Now().UnixNano()
bucket := "crussell"
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
}
}
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
}
}
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, 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
`, 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)
http.Error(w, "Failed to save image record", http.StatusInternalServerError)
return
}
for _, tag := range tags {
if tag == "" {
continue
}
_, err = db.DB.Exec(r.Context(), `
INSERT INTO tags (name) VALUES ($1)
ON CONFLICT (name) DO NOTHING
`, tag)
if err != nil {
log.Printf("Failed to insert tag %s: %v", tag, err)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Image{
ID: imgID,
URL: fullURLs.Avif,
ThumbnailURL: thumbURLs.Webp,
Full: fullURLs,
Thumb: thumbURLs,
TagNames: tags,
CreatedAt: time.Now(),
})
}
func DeleteImage(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
adminRole, _ := r.Context().Value(mw.UserRoleKey).(string)
if adminRole != "admin" {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
imageID := chi.URLParam(r, "id")
if imageID == "" || !validators.IsValidID(imageID) {
http.Error(w, "Image not found", http.StatusNotFound)
return
}
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,
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)
http.Error(w, "Image not found", http.StatusNotFound)
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
}
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)
if err != nil {
log.Printf("Failed to delete image: %v", err)
http.Error(w, "Failed to delete image", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func extractKey(url string) string {
// URL format: https://endpoint/bucket/portfolio/1234567890.jpg
// Need to return: portfolio/1234567890.jpg
// Find the bucket segment: skip past scheme://endpoint/
idx := strings.Index(url, "://")
if idx == -1 {
return url
}
rest := url[idx+3:] // skip "://"
// Now rest = "endpoint/bucket/portfolio/1234567890.jpg"
// Skip first path segment (endpoint)
slashIdx := strings.Index(rest, "/")
if slashIdx == -1 {
return rest
}
rest = rest[slashIdx+1:] // "bucket/portfolio/1234567890.jpg"
// Skip second path segment (bucket)
slashIdx = strings.Index(rest, "/")
if slashIdx == -1 {
return rest
}
return rest[slashIdx+1:] // "portfolio/1234567890.jpg"
}
func GetImage(w http.ResponseWriter, r *http.Request) {
imageID := chi.URLParam(r, "id")
// Validate input length
if err := validateInputLength(imageID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var img Image
// Lookup by timestamp (nanosecond Unix epoch from URL)
// Only allow numeric timestamps to prevent pattern enumeration
timestampMatch, _ := regexp.Compile(`^\d{15,20}$`)
if !timestampMatch.MatchString(imageID) {
log.Printf("Invalid image ID format: %s", imageID)
http.Error(w, "Image not found", http.StatusNotFound)
return
}
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,
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,
&fullAvif, &fullWebp, &fullJpg, &fullJxl,
&thumbAvif, &thumbWebp, &thumbJpg,
)
if err != nil {
log.Printf("Failed to get image: %v", err)
http.Error(w, "Image not found", http.StatusNotFound)
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)
}