Files
Crussell/backend/handlers/portfolio/images.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

1124 lines
31 KiB
Go

package portfolio
import (
"bytes"
"context"
"crussell/db"
"crussell/clock"
"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.Conn.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
}
}
if err := rows.Err(); err != nil {
return nil, err
}
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"`
}
// parseCursor splits a "createdAt|id" cursor string into its components.
type ImageListResponse struct {
Images []Image `json:"images"`
NextCursor *string `json:"next_cursor,omitempty"`
}
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
cursorStr := r.URL.Query().Get("cursor")
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = 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::text = 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
`, formatCols, similaritySum, whereClause, filterClauses)
var cursorArgs []interface{}
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
cursorArgs = []interface{}{cursorCreatedAt, cursorID}
havingIdx := len(cleanTags) + len(filterArgs) + 1
query += fmt.Sprintf(" HAVING (created_at, id) < ($%d, $%d)", havingIdx, havingIdx+1)
}
query += " ORDER BY match_count DESC, relevance DESC, created_at DESC, id DESC"
argOffset = len(filterArgs) + len(cleanTags) + len(cursorArgs)
query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+len(cursorArgs)+1)
copy(queryArgs, filterArgs)
for i, t := range cleanTags {
queryArgs[len(filterArgs)+i] = t
}
for i, ca := range cursorArgs {
queryArgs[len(filterArgs)+len(cleanTags)+i] = ca
}
queryArgs[len(filterArgs)+len(cleanTags)+len(cursorArgs)] = limit
args = queryArgs
} else if tagFilter != "" {
argOffset := len(filterArgs)
searchPattern := "%" + tagFilter + "%"
searchIdx := argOffset + 1
query = fmt.Sprintf(`
SELECT id, url, thumbnail_url, tag_names, created_at%s,
CASE WHEN t = $%d THEN 2 ELSE 1 END as match_priority,
similarity(t, $%d) as relevance
FROM images, unnest(tag_names) as t
WHERE 1=1%s AND t ILIKE '%%' || $%d || '%%'
`, formatCols, searchIdx, searchIdx, filterClauses, searchIdx)
cursorArgs := []interface{}{}
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
query += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", searchIdx+1, searchIdx+2)
cursorArgs = append(cursorArgs, cursorCreatedAt, cursorID)
}
query += " ORDER BY match_priority DESC, relevance DESC, created_at DESC, id DESC"
query += fmt.Sprintf(" LIMIT $%d", searchIdx+1+len(cursorArgs))
queryArgs := make([]interface{}, searchIdx+1+len(cursorArgs))
copy(queryArgs[:argOffset], filterArgs)
queryArgs[argOffset] = searchPattern
for i, ca := range cursorArgs {
queryArgs[searchIdx+i] = ca
}
queryArgs[searchIdx+len(cursorArgs)] = limit
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
`, formatCols, filterClauses)
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
query += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", argOffset+1, argOffset+2)
filterArgs = append(filterArgs, cursorCreatedAt, cursorID)
argOffset += 2
}
query += " ORDER BY created_at DESC, id DESC"
query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
queryArgs := make([]interface{}, len(filterArgs)+1)
copy(queryArgs, filterArgs)
queryArgs[len(filterArgs)] = limit
args = queryArgs
}
rows, err := db.Conn.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 err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if images == nil {
images = []Image{}
}
var nextCursor *string
if len(images) > 0 {
last := images[len(images)-1]
cursor := last.CreatedAt.UTC().Format(time.RFC3339) + "|" + last.ID
nextCursor = &cursor
}
json.NewEncoder(w).Encode(ImageListResponse{
Images: images,
NextCursor: nextCursor,
})
}
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.Conn.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 err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if tags == nil {
tags = []Tag{}
}
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.Conn.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
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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.Conn.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
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// 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
})
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.Conn.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 err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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
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
}
if err := s3.Client.HealthCheck(r.Context()); err != nil {
log.Printf("S3 pre-flight health check failed: %v", err)
http.Error(w, "Storage backend is unreachable — upload cannot proceed", http.StatusServiceUnavailable)
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 := clock.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
}
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
var imgID string
err = tx.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 = tx.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)
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(Image{
ID: imgID,
URL: fullURLs.Avif,
ThumbnailURL: thumbURLs.Webp,
Full: fullURLs,
Thumb: thumbURLs,
TagNames: tags,
CreatedAt: clock.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.Conn.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)
}
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
_, err = tx.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
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", 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.Conn.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
}
json.NewEncoder(w).Encode(img)
}