package portfolio import ( "bytes" "context" "crussell/db" "crussell/internal/s3" "crussell/mw" "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 // processImage strips metadata and auto-orients the image func processImage(data []byte, quality int) ([]byte, error) { // Decode the image - this automatically applies EXIF orientation // and strips all metadata (EXIF, GPS, etc.) img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) if err != nil { return nil, fmt.Errorf("failed to decode image: %w", err) } // Encode to JPEG without any metadata var buf bytes.Buffer err = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(quality)) if err != nil { return nil, fmt.Errorf("failed to encode image: %w", err) } return buf.Bytes(), nil } // validateInputLength returns an error if input exceeds max length func validateInputLength(input string) error { if len(input) > MaxInputLength { return fmt.Errorf("input exceeds maximum length of %d characters", MaxInputLength) } return nil } // getAllowedCategories fetches all unique category prefixes from existing tags func getAllowedCategories(ctx context.Context) (map[string]bool, error) { rows, err := db.DB.Query(ctx, ` SELECT DISTINCT SPLIT_PART(t, ':', 1) as category FROM images, unnest(tag_names) as t WHERE t LIKE '%:%' `) if err != nil { return nil, err } defer rows.Close() categories := make(map[string]bool) for rows.Next() { var cat string if err := rows.Scan(&cat); err == nil && cat != "" { categories[cat] = true } } return categories, nil } type Image struct { ID string `json:"id"` URL string `json:"url"` ThumbnailURL string `json:"thumbnail_url"` TagNames []string `json:"tag_names"` CreatedAt time.Time `json:"created_at"` } type Tag struct { ID string `json:"id"` Name string `json:"name"` } func ListImages(w http.ResponseWriter, r *http.Request) { tagFilter := r.URL.Query().Get("tag") tagsFilter := r.URL.Query().Get("tags") // Validate input length if tagFilter != "" { if err := validateInputLength(tagFilter); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } if tagsFilter != "" { if err := validateInputLength(tagsFilter); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } limit := 20 offset := 0 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { limit = parsed } } if o := r.URL.Query().Get("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } // Validate filter categories against allowed list from DB allowedCategories, err := getAllowedCategories(r.Context()) if err != nil { log.Printf("Failed to get allowed categories: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } filterClauses := "" filterArgs := []interface{}{} for key, values := range r.URL.Query() { if len(values) == 0 || values[0] == "" { continue } match, _ := regexp.Compile(`^filter\[(.+)\]$`) if match != nil { matches := match.FindStringSubmatch(key) if len(matches) == 2 { category := matches[1] value := values[0] // Validate category exists if !allowedCategories[category] { continue // Skip invalid categories silently for backward compatibility } // Validate input length if err := validateInputLength(category + ":" + value); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1) filterArgs = append(filterArgs, category+":"+value) } } } var query string var args []interface{} 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, COUNT(t) as match_count, %s as relevance FROM images, unnest(tag_names) as t WHERE %s%s GROUP BY id, url, thumbnail_url, tag_names, created_at ORDER BY match_count DESC, relevance DESC, created_at DESC LIMIT $%d OFFSET $%d `, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2) queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+2) copy(queryArgs, filterArgs) for i, t := range cleanTags { queryArgs[len(filterArgs)+i] = t } queryArgs[len(filterArgs)+len(cleanTags)] = limit queryArgs[len(filterArgs)+len(cleanTags)+1] = offset args = queryArgs } else if tagFilter != "" { argOffset := len(filterArgs) searchPattern := "%" + tagFilter + "%" query = fmt.Sprintf(` SELECT id, url, thumbnail_url, tag_names, created_at, CASE WHEN t = $1 THEN 2 ELSE 1 END as match_priority, similarity(t, $1) as relevance FROM images, unnest(tag_names) as t WHERE t ILIKE '%%' || $1 || '%%%s' ORDER BY match_priority DESC, relevance DESC, created_at DESC LIMIT $%d OFFSET $%d `, filterClauses, argOffset+2, argOffset+3) queryArgs := make([]interface{}, len(filterArgs)+3) queryArgs[0] = searchPattern copy(queryArgs[1:], filterArgs) queryArgs[len(filterArgs)+1] = limit queryArgs[len(filterArgs)+2] = offset args = queryArgs } else { argOffset := len(filterArgs) query = fmt.Sprintf(` SELECT id, url, thumbnail_url, tag_names, created_at, 0 as match_count, 0.0 as relevance FROM images WHERE 1=1%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d `, filterClauses, argOffset+1, argOffset+2) queryArgs := make([]interface{}, len(filterArgs)+2) copy(queryArgs, filterArgs) queryArgs[len(filterArgs)] = limit queryArgs[len(filterArgs)+1] = offset args = queryArgs } rows, err := db.DB.Query(r.Context(), query, args...) if err != nil { log.Printf("Failed to list images: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var images []Image for rows.Next() { var img Image var matchCount int var relevance float64 if err := rows.Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt, &matchCount, &relevance); err != nil { log.Printf("Failed to scan image: %v", err) continue } images = append(images, img) } if images == nil { images = []Image{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(images) } func ListTags(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") var query string var args []interface{} // Query tags from images.tag_names column (stored as array) if q != "" { query = ` SELECT DISTINCT tag FROM ( SELECT unnest(tag_names) as tag FROM images WHERE tag_names IS NOT NULL ) t WHERE tag ILIKE '%' || $1 || '%' ORDER BY tag LIMIT 20 ` args = []interface{}{q} } else { query = ` SELECT DISTINCT tag FROM ( SELECT unnest(tag_names) as tag FROM images WHERE tag_names IS NOT NULL ) t ORDER BY tag LIMIT 20 ` } rows, err := db.DB.Query(r.Context(), query, args...) if err != nil { log.Printf("Failed to list tags: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var tags []Tag for rows.Next() { var name string if err := rows.Scan(&name); err != nil { log.Printf("Failed to scan tag: %v", err) continue } tags = append(tags, Tag{ID: name, Name: name}) } if tags == nil { tags = []Tag{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tags) } type FilterCategory struct { Category string `json:"category"` Values []FilterValue `json:"values"` } type FilterValue struct { Value string `json:"value"` Count int `json:"count"` } func ListFilters(w http.ResponseWriter, r *http.Request) { tagFilter := r.URL.Query().Get("tag") tagsFilter := r.URL.Query().Get("tags") // Get all filter params selectedCategories := make(map[string]string) for key, values := range r.URL.Query() { if strings.HasPrefix(key, "filter[") && len(values) > 0 && values[0] != "" { category := strings.Trim(key, "[]") category = strings.TrimPrefix(category, "filter[") selectedCategories[category] = values[0] } } // Build base query baseQuery := "SELECT DISTINCT id FROM images WHERE 1=1" args := []interface{}{} argNum := 1 if tagFilter != "" { searchPattern := "%" + tagFilter + "%" baseQuery += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE t ILIKE $%d)", argNum) args = append(args, searchPattern) argNum++ } if tagsFilter != "" { tagList := strings.Split(tagsFilter, ",") conditions := make([]string, len(tagList)) for i := range tagList { tagList[i] = "%" + strings.TrimSpace(tagList[i]) + "%" conditions[i] = fmt.Sprintf("t ILIKE $%d", argNum) args = append(args, tagList[i]) argNum++ } baseQuery += " AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE " + strings.Join(conditions, " OR ") + ")" } // Build category filters for OTHER categories otherFilters := make([]string, 0) otherArgs := make([]interface{}, len(args)) copy(otherArgs, args) otherArgNum := argNum for cat, val := range selectedCategories { tagValue := cat + ":" + val otherFilters = append(otherFilters, fmt.Sprintf("AND $%d = ANY(tag_names)", otherArgNum)) otherArgs = append(otherArgs, tagValue) otherArgNum++ } // Build the filter count query filterQuery := ` SELECT SPLIT_PART(t, ':', 1) as category, SPLIT_PART(t, ':', 2) as value, COUNT(*) as count FROM (` + baseQuery + `) as img_ids JOIN images ON images.id = img_ids.id JOIN unnest(images.tag_names) as t ON true WHERE t LIKE '%:%' ` // If there are selected categories, we need two queries if len(selectedCategories) > 0 { // Query 1: Get all categories with filters applied (for unselected categories) filterClause := "" if len(otherFilters) > 0 { filterClause = " " + strings.Join(otherFilters, " ") } filteredQuery := filterQuery + filterClause + " GROUP BY category, value ORDER BY category, count DESC" rows, err := db.DB.Query(r.Context(), filteredQuery, otherArgs...) if err != nil { log.Printf("Failed to list filters: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } results := make(map[string]map[string]int) for rows.Next() { var category, value string var count int if err := rows.Scan(&category, &value, &count); err != nil { continue } // Skip selected categories in this query if _, isSelected := selectedCategories[category]; isSelected { continue } if results[category] == nil { results[category] = make(map[string]int) } results[category][value] = count } rows.Close() // Query 2: Get selected categories WITHOUT filters (show all options) unfilteredQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC" rows, err = db.DB.Query(r.Context(), unfilteredQuery, args...) if err != nil { log.Printf("Failed to list unfiltered filters: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var category, value string var count int if err := rows.Scan(&category, &value, &count); err != nil { continue } // Only include selected categories if _, isSelected := selectedCategories[category]; !isSelected { continue } if results[category] == nil { results[category] = make(map[string]int) } results[category][value] = count } // Convert to response var filters []FilterCategory for cat, values := range results { var fv []FilterValue for val, cnt := range values { fv = append(fv, FilterValue{Value: val, Count: cnt}) } sort.Slice(fv, func(i, j int) bool { return fv[i].Count > fv[j].Count }) filters = append(filters, FilterCategory{Category: cat, Values: fv}) } sort.Slice(filters, func(i, j int) bool { sumI := 0 for _, v := range filters[i].Values { sumI += v.Count } sumJ := 0 for _, v := range filters[j].Values { sumJ += v.Count } return sumI > sumJ }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(filters) return } // No selected categories - simple query finalQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC" rows, err := db.DB.Query(r.Context(), finalQuery, args...) if err != nil { log.Printf("Failed to list filters: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var filters []FilterCategory currentCategory := "" var currentValues []FilterValue for rows.Next() { var category, value string var count int if err := rows.Scan(&category, &value, &count); err != nil { continue } if category != currentCategory { if currentCategory != "" { filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues}) } currentCategory = category currentValues = []FilterValue{} } currentValues = append(currentValues, FilterValue{Value: value, Count: count}) } if currentCategory != "" { filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues}) } // Deduplicate categories - use a map to ensure each category appears only once seenCategories := make(map[string]bool) var uniqueFilters []FilterCategory for _, f := range filters { if !seenCategories[f.Category] { seenCategories[f.Category] = true uniqueFilters = append(uniqueFilters, f) } } filters = uniqueFilters w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(filters) } func UploadImage(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } adminRole, _ := r.Context().Value(mw.UserRoleKey).(string) if adminRole != "admin" { http.Error(w, "Admin access required", http.StatusForbidden) return } if s3.Client == nil { log.Printf("S3 client not initialized") http.Error(w, "Storage not configured", http.StatusInternalServerError) return } r.ParseMultipartForm(10 << 20) file, header, err := r.FormFile("file") if err != nil { log.Printf("Failed to get file: %v", err) http.Error(w, "No file provided", http.StatusBadRequest) return } defer file.Close() thumbFile, thumbHeader, err := r.FormFile("thumbnail") if err != nil { log.Printf("Failed to get thumbnail: %v", err) http.Error(w, "No thumbnail provided", http.StatusBadRequest) return } defer thumbFile.Close() tagsStr := r.FormValue("tags") var tags []string if tagsStr != "" { tags = strings.Split(tagsStr, ",") for i := range tags { tags[i] = strings.TrimSpace(tags[i]) } } // Use nanosecond timestamp for unique keys timestamp := time.Now().UnixNano() // Extract extension from main file mainExt := ".jpg" if idx := strings.LastIndex(header.Filename, "."); idx != -1 { mainExt = strings.ToLower(header.Filename[idx:]) } // Extract extension from thumbnail file thumbExt := ".jpg" if idx := strings.LastIndex(thumbHeader.Filename, "."); idx != -1 { thumbExt = strings.ToLower(thumbHeader.Filename[idx:]) } key := fmt.Sprintf("portfolio/%d%s", timestamp, mainExt) thumbKey := fmt.Sprintf("portfolio/%d_thumb%s", timestamp, thumbExt) fileBytes, err := io.ReadAll(file) if err != nil { log.Printf("Failed to read file: %v", err) http.Error(w, "Failed to read file", http.StatusInternalServerError) return } // Images are already processed by frontend (compressed, metadata stripped) // Skip re-encoding - just use the uploaded bytes directly thumbBytes, err := io.ReadAll(thumbFile) if err != nil { log.Printf("Failed to read thumbnail: %v", err) http.Error(w, "Failed to read thumbnail", http.StatusInternalServerError) return } // Thumbnail is already processed by frontend - use as-is bucket := "crussell" if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes)); err != nil { log.Printf("Failed to upload image to S3: %v", err) http.Error(w, "Failed to upload image", http.StatusInternalServerError) return } if err := s3.Client.Upload(r.Context(), bucket, thumbKey, bytes.NewReader(thumbBytes)); err != nil { log.Printf("Failed to upload thumbnail to S3: %v", err) http.Error(w, "Failed to upload thumbnail", http.StatusInternalServerError) return } url, err := s3.Client.GetURL(r.Context(), bucket, key) if err != nil { log.Printf("Failed to get image URL: %v", err) http.Error(w, "Failed to get URL", http.StatusInternalServerError) return } thumbURL, err := s3.Client.GetURL(r.Context(), bucket, thumbKey) if err != nil { log.Printf("Failed to get thumbnail URL: %v", err) http.Error(w, "Failed to get thumbnail URL", http.StatusInternalServerError) return } var imgID string err = db.DB.QueryRow(r.Context(), ` INSERT INTO images (url, thumbnail_url, tag_names) VALUES ($1, $2, $3) RETURNING id `, url, thumbURL, tags).Scan(&imgID) if err != nil { log.Printf("Failed to insert image record: %v", err) http.Error(w, "Failed to save image record", http.StatusInternalServerError) return } for _, tag := range tags { if tag == "" { continue } _, err = db.DB.Exec(r.Context(), ` INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING `, tag) if err != nil { log.Printf("Failed to insert tag %s: %v", tag, err) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(Image{ ID: imgID, URL: url, ThumbnailURL: thumbURL, TagNames: tags, CreatedAt: time.Now(), }) } func DeleteImage(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } adminRole, _ := r.Context().Value(mw.UserRoleKey).(string) if adminRole != "admin" { http.Error(w, "Admin access required", http.StatusForbidden) return } imageID := chi.URLParam(r, "id") var url, thumbURL string err := db.DB.QueryRow(r.Context(), ` SELECT url, thumbnail_url FROM images WHERE id = $1 `, imageID).Scan(&url, thumbURL) if err != nil { log.Printf("Failed to find image: %v", err) http.Error(w, "Image not found", http.StatusNotFound) return } if s3.Client != nil { key := extractKey(url) thumbKey := extractKey(thumbURL) s3.Client.Delete(context.Background(), "crussell", key) s3.Client.Delete(context.Background(), "crussell", thumbKey) } _, err = db.DB.Exec(r.Context(), `DELETE FROM images WHERE id = $1`, imageID) if err != nil { log.Printf("Failed to delete image: %v", err) http.Error(w, "Failed to delete image", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func extractKey(url string) string { parts := strings.Split(url, "/") if len(parts) > 0 { return parts[len(parts)-1] } return url } 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 + ".%" err := db.DB.QueryRow(r.Context(), ` SELECT id, url, thumbnail_url, tag_names, created_at 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) if err != nil { log.Printf("Failed to get image: %v", err) http.Error(w, "Image not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(img) }