From 167d014ca598e9c959e022fee1aac9bdc94d6d45 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Sat, 4 Apr 2026 08:45:35 -0400 Subject: [PATCH 1/2] Use presigned S3 URLs for serving images instead of proxying bytes Browser tags can't attach JWT headers, causing 401s. The image endpoint now returns a time-limited presigned S3 URL via authenticated API call, which the frontend fetches and uses directly. Co-Authored-By: Claude Opus 4.6 --- blueprints/conversation/__init__.py | 11 ++++------- raggr-frontend/src/api/conversationService.ts | 11 +++++++++-- raggr-frontend/src/components/QuestionBubble.tsx | 12 ++++++++++-- utils/s3_client.py | 10 ++++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index d19ace2..2ebc915 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -3,7 +3,7 @@ import json import logging import uuid -from quart import Blueprint, Response, jsonify, make_response, request +from quart import Blueprint, jsonify, make_response, request from quart_jwt_extended import ( get_jwt_identity, jwt_refresh_token_required, @@ -12,6 +12,7 @@ from quart_jwt_extended import ( import blueprints.users.models from utils.image_process import analyze_user_image from utils.image_upload import ImageValidationError, process_image +from utils.s3_client import generate_presigned_url as s3_presigned_url from utils.s3_client import get_image as s3_get_image from utils.s3_client import upload_image as s3_upload_image @@ -134,15 +135,11 @@ async def upload_image(): @jwt_refresh_token_required async def serve_image(image_key: str): try: - image_bytes, content_type = await s3_get_image(image_key) + url = await s3_presigned_url(image_key) except Exception: return jsonify({"error": "Image not found"}), 404 - return Response( - image_bytes, - content_type=content_type, - headers={"Cache-Control": "private, max-age=3600"}, - ) + return jsonify({"url": url}) @conversation_blueprint.post("/stream-query") diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 788089e..1135058 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -147,8 +147,15 @@ class ConversationService { return await response.json(); } - getImageUrl(imageKey: string): string { - return `/api/conversation/image/${imageKey}`; + async getPresignedImageUrl(imageKey: string): Promise { + const response = await userService.fetchWithRefreshToken( + `${this.conversationBaseUrl}/image/${imageKey}`, + ); + if (!response.ok) { + throw new Error("Failed to get image URL"); + } + const data = await response.json(); + return data.url; } async streamQuery( diff --git a/raggr-frontend/src/components/QuestionBubble.tsx b/raggr-frontend/src/components/QuestionBubble.tsx index b131c11..081c726 100644 --- a/raggr-frontend/src/components/QuestionBubble.tsx +++ b/raggr-frontend/src/components/QuestionBubble.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { cn } from "../lib/utils"; import { conversationService } from "../api/conversationService"; @@ -7,6 +8,13 @@ type QuestionBubbleProps = { }; export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { + const [imageUrl, setImageUrl] = useState(null); + + useEffect(() => { + if (!image_key) return; + conversationService.getPresignedImageUrl(image_key).then(setImageUrl).catch(() => {}); + }, [image_key]); + return (
{ "shadow-sm shadow-leaf/10", )} > - {image_key && ( + {imageUrl && ( Uploaded image diff --git a/utils/s3_client.py b/utils/s3_client.py index 443ef82..4d941a5 100644 --- a/utils/s3_client.py +++ b/utils/s3_client.py @@ -47,6 +47,16 @@ async def get_image(key: str) -> tuple[bytes, str]: return body, content_type +async def generate_presigned_url(key: str, expires_in: int = 3600) -> str: + async with _get_client() as client: + url = await client.generate_presigned_url( + "get_object", + Params={"Bucket": S3_BUCKET_NAME, "Key": key}, + ExpiresIn=expires_in, + ) + return url + + async def delete_image(key: str) -> None: async with _get_client() as client: await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key) -- 2.49.1 From 30db71d134e154096a8479fe5ec92b7baba77c15 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Sat, 4 Apr 2026 08:49:01 -0400 Subject: [PATCH 2/2] Clean up presigned URL implementation: remove dead fields, fix error handling - Remove unused image_url from upload response and TS type - Remove bare except in serve_image that masked config errors as 404s - Add error state and broken-image placeholder in QuestionBubble Co-Authored-By: Claude Opus 4.6 --- blueprints/conversation/__init__.py | 13 ++----------- raggr-frontend/src/api/conversationService.ts | 2 +- raggr-frontend/src/components/QuestionBubble.tsx | 15 ++++++++++++++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index 2ebc915..4175eff 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -123,22 +123,13 @@ async def upload_image(): await s3_upload_image(processed_bytes, key, output_content_type) - return jsonify( - { - "image_key": key, - "image_url": f"/api/conversation/image/{key}", - } - ) + return jsonify({"image_key": key}) @conversation_blueprint.get("/image/") @jwt_refresh_token_required async def serve_image(image_key: str): - try: - url = await s3_presigned_url(image_key) - except Exception: - return jsonify({"error": "Image not found"}), 404 - + url = await s3_presigned_url(image_key) return jsonify({"url": url}) diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 1135058..efd4b58 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -125,7 +125,7 @@ class ConversationService { async uploadImage( file: File, conversationId: string, - ): Promise<{ image_key: string; image_url: string }> { + ): Promise<{ image_key: string }> { const formData = new FormData(); formData.append("file", file); formData.append("conversation_id", conversationId); diff --git a/raggr-frontend/src/components/QuestionBubble.tsx b/raggr-frontend/src/components/QuestionBubble.tsx index 081c726..a3678e4 100644 --- a/raggr-frontend/src/components/QuestionBubble.tsx +++ b/raggr-frontend/src/components/QuestionBubble.tsx @@ -9,10 +9,17 @@ type QuestionBubbleProps = { export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { const [imageUrl, setImageUrl] = useState(null); + const [imageError, setImageError] = useState(false); useEffect(() => { if (!image_key) return; - conversationService.getPresignedImageUrl(image_key).then(setImageUrl).catch(() => {}); + conversationService + .getPresignedImageUrl(image_key) + .then(setImageUrl) + .catch((err) => { + console.error("Failed to load image:", err); + setImageError(true); + }); }, [image_key]); return ( @@ -25,6 +32,12 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { "shadow-sm shadow-leaf/10", )} > + {imageError && ( +
+ 🖼️ + Image failed to load +
+ )} {imageUrl && (