package portfolio import ( "bytes" "context" "crussell/clock" "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.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 } 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 { http.Error(w, err.Error(), 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, " + ") 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.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 } r.ParseMultipartForm(50 << 20) 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 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/ _, 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 { 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) }