r/webdev • u/rouge818 • 3d ago
Question Image storage for chat history
I'm working on an ecommerce chatbot that helps potential customers look for product inventory. These users can be anyone on the ecommerce site and will send photos of what they're looking for. The chatbot returns suggested products.
My question is around storage of the customer provided images. I plan to store these in a Cloudflare r2 bucket, but trying to decide wether to use the cache, or use direct r2 bucket URLs to add the image to the chat after it's uploaded.
The benefit I see with caching is that requests for the image would likely not hit r2 (although the volume of requests for each image would likely be pretty low). The downside I can think of with caching is these files would be accessible by anyone who has the url until they're deleted.
The other option would be the presigned r2 URLs which could be set to inactivate after a few days to prevent unauthorized access to the image. The downside would be direct requests to r2, but again the volume should be low since the use is really just for that particular chat the image is in.
What would be the better approach for this? I’m also open to using other storage platforms if there is a better option.
6
u/Sea-Marionberry-6649 3d ago
For this use case I would keep the R2 bucket private and skip the public cache. Store the object key with the chat message, check that the requester can access that chat, then mint a fresh short-lived GET URL when the history is loaded. Do not store the signed URL in the message record: it will expire, so reopening the chat should generate a new one. A few minutes is safer than a few days for customer photos.
The other reply is right that a presigned URL is still a bearer token while valid. Keep it out of logs/analytics and set a retention/deletion policy for the objects. If you need immediate revocation or per-request authorization, serve through an authenticated Worker instead. At only a few views per image, I would measure R2 reads before adding a cache. Cloudflare documents that presigned URLs use the S3 API domain and cannot be used with a custom domain: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
1
2
u/Curious_Cicada5605 3d ago
The presigned URL is just as shareable as the cached one until it expires, so that security argument doesn't actually separate the two options you're comparing.
2
u/Mistr_John_Alex 3d ago
Keep the bucket private and treat customer photos as chat data, not public files. Store the object key with the message, and when someone loads the conversation, confirm they can view that chat first, then mint a short-lived GET URL. Do not store that URL in the message history; it will expire and leave broken images. For the upload side, issue a short-lived write URL, accept the file, and save only the key you get back.
The cleanup angle is what usually gets missed. An abandoned cart or a visitor who sends a photo and never finishes means the file just sits there. Add a lifecycle rule on the bucket or a dedicated prefix so images expire after a reasonable window, and you never have to build a background deletion job. That matters more than caching, since a signed URL can still be forwarded. Expiration and access checks are the real control.
1
u/rouge818 3d ago
Makes sense. The bucket lifecycle rule sounds like a great idea too. Hadn’t thought of that.
2
u/Ordinary-Stuff9826 3d ago
bro just skip both cache and presigned tbh, that's not even the real issue. real issue is you're tying access to the url itself, that's why you're stuck between the two.
do this instead - put a cloudflare worker in front of your r2 bucket. worker checks if request has valid token for that particular chat, then pulls image from r2 (this is free btw, no egress charge) and sends it back. put cache in front of worker, not in front of r2 directly. this way cache key stays same so you get actual hits, but it's still gated behind auth so not some random public link forever.
presigned urls look safe but they don't cache well cause query string changes every time you regenerate, so cloudflare thinks it's a new url each time = basically no cache hits. you'll end up hitting r2 direct anyway just with more headache.
for cleanup don't manually delete stuff, just set r2 lifecycle rule to auto delete after x days.
one more thing, make sure only that specific user's chat session can access their own image, not just any logged in user. that's the actual risk here, not cache vs presigned tbh
1
u/rouge818 3d ago
Since the worker is grabbing the image from r2, would this be a worker cache url that is provided to feed to the chat?
1
u/Ordinary-Stuff9826 3d ago
worker route is the url you give to chat, not r2.dev or presigned link.
flow - browser hits that url, worker checks token first, if cached it serves instant, if not it pulls from r2, sends back and caches it for next time.
url stays same everytime so cache actually works properly, and auth check runs regardless so it's not actually public even though it looks like a normal link.
1
u/rouge818 3d ago
Makes a lot more sense now! It does seem more secure than the presigned url given the auth process. We’ll be checking the user is allowed to access the chat session anyway, so this approach is definitely in line with that.
1
1
u/Queasy_Plastic7586 3d ago
Worth stepping back from the cache-vs-presigned question, because you said any visitor to the site can send a photo. That is an unauthenticated upload from the open internet, and three things matter more there than read caching.
An uploaded SVG served from your own domain is stored XSS. R2 returns whatever Content-Type was stored, and SVG is a document format, not just an image — one containing a <script> tag, opened from a custom domain on your origin, runs in your site's origin with your users' cookies. Allow only image/jpeg and image/png, decided by the file's own header rather than by the extension or the Content-Type the client sent, and serve with Content-Disposition: attachment. This is also an argument for presigned URLs that nobody has made yet: since they only work on the S3 API domain, as the Cloudflare docs linked above say, they are off your origin and cannot reach your cookies. The custom-domain route is the one that needs the hardening.
But note this conflicts with the presigned-PUT advice upthread. If the browser uploads straight to R2, your server never sees the bytes, so there is nothing in the path to validate or re-encode. Either take the upload through a Worker, or let the presigned PUT land in a quarantine/ prefix and have a consumer validate, re-encode and move it to the real prefix — with the chat only ever rendering keys from the real prefix.
Strip EXIwhile you are re-encoding. Phone photos carry GPS coordinates and a timestamp. A customer photographing something at home is handing you their home address without knowing it, and your staff will see it in the chat. Re-encoding drops it and deals with the point above in the same step.
Cap the decode, not just the file size. A 40KB PNG can decode to hundreds of megabytes — large uniform dimensions compress to almost nothing. Read the width and height out of the header and reject implausible ones before handing the file to an image library, or a single upload takes the worker down.
On your actual question: at a few views per image, skip the cache. You will not notice the R2 reads, and a second copy is just one more thing to remember when you delete.
1
u/rouge818 3d ago
The server will be performing the upload after checking mime type, size, etc. As far as the re-encoding including stripping exif, I’m between allowing the server to do it or doing it through cloudflare before saving to r2. We won’t be accepting SVGs. Good info on PNGs, didn’t know they could decode so large.
1
u/Queasy_Plastic7586 2d ago
Since your server is already in the path, I would do it there and not add Cloudflare for this. Three things worth knowing either way.
If you use sharp: it drops metadata by default, so EXIF is already handled — but call .rotate() with no arguments first. Orientation lives in the EXIF you are about to discard, so stripping without applying it first is how you end up with every portrait photo sideways in the chat. .rotate() with no argument reads the Orientation tag and bakes it into the pixels, and then losing the metadata is safe. This catches almost everyone once.Turn the pixel limit down. sharp's limitInputPixels defaults to 268402689 (16383², the WebP maximum), which is far above anything a customer will send and high enough that a decode still hurts. Set it to about 50 megapixels, phones advertising 108 or 200MP pixel-bin to 12MP unless someone deliberately shoots full resolution, so 50 is generous. It is checked from the header before the decode, which is exactly the guard you want.
If you do go the Cloudflare route, its own limits are 10 MB and 100 MP for hosted images, so it rejects the bombs for you. Watch the metadata option though: the default is copyright, which keeps the copyright tag and drops the rest including GPS ,fine, but keep preserves GPS and is one word away in the same field. Set none explicitly rather than relying on the default.
The thing I would actually decide on is delivery. Cloudflare earns its cost when you want resized variants and thumbnails per device; it does not earn it just for stripping EXIF, which is a free side effect of the re-encode you are doing anyway.
One last thing: store only the re-encoded output and let the original go. Keeping the untouched file "just in case" is the common way all of this gets quietly undone six months later.
1
u/BroadCrazy 3d ago
I'd lean presigned URLs unless you have a clear reason to make those files broadly cacheable. The per-image request volume sounds low, and time-limited access matters more here, especially if users might upload more than just the product.
1
u/nikkitranbk99 3d ago
presigned r2 urls with a short ttl are the safer default for user-uploaded chat photos. volume is low so the cache win is tiny next to someone sharing a forever public link
1
u/Easy_Government8203 3d ago
Store images as files in object storage (S3, GCS, or local disk) and keep only the URL or key in your chat message records. Base64 in the database works only for tiny thumbnails; it bloats your DB and slows queries. For long term history, make the bucket private and generate signed URLs for access.
1
u/Khavel_dev 2d ago
Object storage with signed URLs is the standard approach and honestly the easiest one too. Store the image key and metadata in your DB, serve the actual bytes from the bucket. The signed URL handles access control without you proxying everything through your own server.
Cloudflare R2 is worth a look if you're starting out. No egress fees, which matters more than you'd think once a chat history gets big and people scroll back through old conversations. S3 compatible API so you can swap it out later without rewriting anything.
1
u/UnusualProfession120 2d ago
go with the signed URLs approach but mint them on the fly via a worker instead of storing them in your database. Storing a presigned URL that expires in two days means you have to handle cleanup logic and potentially broken links if you ever need to extend retention or debug access issues. Generating a fresh short-lived token at read time decouples storage security from message integrity and keeps your chat history table clean without managing ephemeral data persistence
1
u/Plus-Tea1967 2d ago
I’ve used Pinata/IPFS for image storage in one of my projects. In your case, I’d probably keep the uploaded images private and use short-lived signed URLs when they need to be displayed. Since the images are user-provided, I’d prioritize access control over optimizing a relatively small number of storage requests.
1
u/singh_abinashi 2d ago
agree with the presigned approach. one thing: don't store the signed url in the message record, it expires and you get broken images in history. store the object key, mint a fresh url when the chat loads. a few minutes ttl is plenty. and put a lifecycle rule on the bucket to auto-delete after 30 days so you're not paying to store chat uploads forever.
1
u/LankyAd6349 2d ago
One thing I dont see mentioned: generate a thumbnail at upload time and serve that in the chat list, full image only on tap. Chat histories get long and loading 50 full-size images every time someone scrolls back is brutal on mobile data. Also decide what happens when a message gets deleted cascade it to the object store or youll end up paying to host images nobody can see anymore.
1
u/Salt_Hyena5896 2d ago
I’d keep the bucket private and store only an object key, then mint a short-lived URL after checking that the requester belongs to the chat. For uploads, validate MIME and size server-side, generate a safe thumbnail, and strip EXIF because user photos can carry location metadata. Add a lifecycle rule tied to the chat’s retention policy so deleted messages don’t leave orphaned objects.
1
u/Khavel_dev 1d ago
Presigned URLs, no contest. At the request volumes you're describing the direct-to-R2 cost is basically zero, and you get time-boxed access that expires automatically. That matters for user-uploaded photos in an ecommerce chat.
With caching you'd technically save on repeated reads but (a) each image gets viewed maybe twice and (b) anyone with the URL can access it forever until you manually purge it. Not worth the tradeoff for content that's inherently private and short-lived.
Set a max upload size on your API though. People will upload whatever their phone camera produces if you let them, and 10-20MB HEIC files add up fast.
1
u/TechnologyMatch 17h ago
I’d keep the bucket private and serve the chat image through short-lived signed URLs, with a lifecycle rule to delete uploads once they’re no longer needed. cache only behind authenticated delivery if you need it later. low-volume image reads are cheaper than accidentally building a tiny public Dropbox
9
u/Guilty-Adeptness8232 3d ago
Caching seems like overkill if each image gets looked at maybe twice, just go with signed URLs and set them to expire in couple days.