Use presigned S3 URLs for serving images instead of proxying bytes

Browser <img> 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:45:35 -04:00
parent cfa77a1779
commit b62a8b6b3f
4 changed files with 33 additions and 11 deletions

View File

@@ -3,7 +3,7 @@ import json
import logging import logging
import uuid import uuid
from quart import Blueprint, Response, jsonify, make_response, request from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import ( from quart_jwt_extended import (
get_jwt_identity, get_jwt_identity,
jwt_refresh_token_required, jwt_refresh_token_required,
@@ -12,6 +12,7 @@ from quart_jwt_extended import (
import blueprints.users.models import blueprints.users.models
from utils.image_process import analyze_user_image from utils.image_process import analyze_user_image
from utils.image_upload import ImageValidationError, process_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 get_image as s3_get_image
from utils.s3_client import upload_image as s3_upload_image from utils.s3_client import upload_image as s3_upload_image
@@ -134,15 +135,11 @@ async def upload_image():
@jwt_refresh_token_required @jwt_refresh_token_required
async def serve_image(image_key: str): async def serve_image(image_key: str):
try: try:
image_bytes, content_type = await s3_get_image(image_key) url = await s3_presigned_url(image_key)
except Exception: except Exception:
return jsonify({"error": "Image not found"}), 404 return jsonify({"error": "Image not found"}), 404
return Response( return jsonify({"url": url})
image_bytes,
content_type=content_type,
headers={"Cache-Control": "private, max-age=3600"},
)
@conversation_blueprint.post("/stream-query") @conversation_blueprint.post("/stream-query")

View File

@@ -147,8 +147,15 @@ class ConversationService {
return await response.json(); return await response.json();
} }
getImageUrl(imageKey: string): string { async getPresignedImageUrl(imageKey: string): Promise<string> {
return `/api/conversation/image/${imageKey}`; 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( async streamQuery(

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService"; import { conversationService } from "../api/conversationService";
@@ -7,6 +8,13 @@ type QuestionBubbleProps = {
}; };
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
useEffect(() => {
if (!image_key) return;
conversationService.getPresignedImageUrl(image_key).then(setImageUrl).catch(() => {});
}, [image_key]);
return ( return (
<div className="flex justify-end message-enter"> <div className="flex justify-end message-enter">
<div <div
@@ -17,9 +25,9 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
"shadow-sm shadow-leaf/10", "shadow-sm shadow-leaf/10",
)} )}
> >
{image_key && ( {imageUrl && (
<img <img
src={conversationService.getImageUrl(image_key)} src={imageUrl}
alt="Uploaded image" alt="Uploaded image"
className="max-w-full rounded-xl mb-2" className="max-w-full rounded-xl mb-2"
/> />

View File

@@ -47,6 +47,16 @@ async def get_image(key: str) -> tuple[bytes, str]:
return body, content_type 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 def delete_image(key: str) -> None:
async with _get_client() as client: async with _get_client() as client:
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key) await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)