1144 lines
31 KiB
Go
1144 lines
31 KiB
Go
package portfolio
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/images"
|
|
"crussell/internal/s3"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kovidgoyal/imaging"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
// validateInputLength returns an error if input exceeds max length
|
|
|
|
//lint:ignore U1000 referenced from tests
|
|
func processImage(data []byte, quality int) ([]byte, error) {
|
|
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode image: %w", err)
|
|
}
|
|
// Encode at the given quality (1-100)
|
|
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
|
|
}
|
|
|
|
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 {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
if tagsFilter != "" {
|
|
if err := validateInputLength(tagsFilter); err != nil {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", 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
|
|
}
|
|
|
|
var filterClauses strings.Builder
|
|
filterArgs := []any{}
|
|
|
|
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 {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
filterClauses.WriteString(fmt.Sprintf(" AND $%d::text = ANY(tag_names)", len(filterArgs)+1))
|
|
filterArgs = append(filterArgs, category+":"+value)
|
|
}
|
|
}
|
|
}
|
|
|
|
var query string
|
|
var args []any
|
|
|
|
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, " + ")
|
|
|
|
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.String())
|
|
|
|
var cursorArgs []any
|
|
if cursorStr != "" {
|
|
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
|
if err != nil {
|
|
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
cursorArgs = []any{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([]any, 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.String(), searchIdx)
|
|
|
|
cursorArgs := []any{}
|
|
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([]any, 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.String())
|
|
|
|
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([]any, 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 []any
|
|
|
|
// 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 = []any{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 := []any{}
|
|
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([]any, 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
|
|
}
|
|
|
|
// #nosec G120 — body size limited by limitBody middleware
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
http.Error(w, "Upload too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
// Non-size parse errors: log but continue — form values (tags) may be available
|
|
log.Printf("Warning: ParseMultipartForm: %v", err)
|
|
}
|
|
|
|
tagsStr := r.FormValue("tags")
|
|
tags := []string{}
|
|
if tagsStr != "" {
|
|
for t := range strings.SplitSeq(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 func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
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)
|
|
if err := s3.Client.Delete(context.Background(), "crussell", key); err != nil {
|
|
slog.Warn("failed to delete S3 object", "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 func() {
|
|
if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, 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/
|
|
_, after, ok := strings.Cut(url, "://")
|
|
if !ok {
|
|
return url
|
|
}
|
|
rest := after // 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 {
|
|
log.Printf("Failed to process request: %v", err)
|
|
http.Error(w, "Invalid request", 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)
|
|
}
|