From f6bb071d7c594fe3a5330492a06403e4f3ee2fe0 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:34:20 +0530 Subject: [PATCH] enh --- .../app/(drawer)/dashboard/products/add.tsx | 12 ++++- .../app/(drawer)/dashboard/products/edit.tsx | 14 +++++- apps/admin-ui/hooks/useUploadToObjectStore.ts | 8 ++-- apps/backend/src/lib/s3-client.ts | 45 ++++++++++++------- .../src/trpc/apis/admin-apis/apis/product.ts | 2 - .../ui/src/components/ImageUploaderNeo.tsx | 4 +- 6 files changed, 59 insertions(+), 26 deletions(-) diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx index e227458..4465099 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/products/add.tsx @@ -5,6 +5,15 @@ import ProductForm from '@/src/components/ProductForm' import { trpc } from '@/src/trpc-client' import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore' +// SKUs store bucket-relative object keys, not URLs. Drop the query string and +// keep the last two path segments: +// https://.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-... +// -> product-images/1.jpg +const toImageKey = (url: string): string => { + const parts = url.split('?')[0].split('/').filter(Boolean) + return parts.slice(-2).join('/') +} + export default function AddProduct() { const createProduct = trpc.admin.product.createProduct.useMutation() const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, { @@ -63,7 +72,8 @@ export default function AddProduct() { let urlCursor = 0 const skus = values.variants.map((variant: any, vIndex: number) => { const count = imageCounts[vIndex] - const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count) + // Send keys, not the presigned URLs. + const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count).map(toImageKey) urlCursor += count return { diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx index ac72345..69f0c97 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx @@ -6,6 +6,15 @@ import ProductForm, { ProductFormRef } from '@/src/components/ProductForm'; import { trpc } from '@/src/trpc-client'; import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'; +// SKUs store bucket-relative object keys, not URLs. Drop the query string and +// keep the last two path segments: +// https://.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-... +// -> product-images/1.jpg +const toImageKey = (url: string): string => { + const parts = url.split('?')[0].split('/').filter(Boolean) + return parts.slice(-2).join('/') +} + export default function EditProduct() { const { id } = useLocalSearchParams(); const productId = Number(id); @@ -140,9 +149,10 @@ export default function EditProduct() { // Existing images (mimeType === null) stay as-is const existingUrls = variantImages[vIndex] ?.filter((img) => img.mimeType === null) - .map((img) => img.url) || [] + .map((img) => toImageKey(img.url)) || [] - const allUrls = [...existingUrls, ...newUrls] + // Send keys, not URLs — the SKU stores the bucket-relative object key. + const allUrls = [...existingUrls, ...newUrls.map(toImageKey)] return { id: variant.id, diff --git a/apps/admin-ui/hooks/useUploadToObjectStore.ts b/apps/admin-ui/hooks/useUploadToObjectStore.ts index 8327cfa..25c8ee7 100644 --- a/apps/admin-ui/hooks/useUploadToObjectStore.ts +++ b/apps/admin-ui/hooks/useUploadToObjectStore.ts @@ -43,9 +43,11 @@ export function useUploadToObjectStorage() { headers: { 'Content-Type': mimeType }, }); - if (!response.ok) { - throw new Error(`Upload ${index + 1} failed with status ${response.status}`); - } + if (!response.ok) { + const body = await response.text(); // + console.error('upload failed', response.status, body); + throw new Error(`Upload ${index + 1} failed with status ${response.status}`); + } // Update progress setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null); diff --git a/apps/backend/src/lib/s3-client.ts b/apps/backend/src/lib/s3-client.ts index 1f8b437..47372ca 100644 --- a/apps/backend/src/lib/s3-client.ts +++ b/apps/backend/src/lib/s3-client.ts @@ -118,10 +118,14 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI try { const client = getAwsClient() - const url = buildObjectUrl(getS3BucketName(), s3Url) - const signedRequest = await client.sign(url, { + // X-Amz-Expires must be a QUERY param, not a header: with signQuery, aws4fetch + // hoists header values into the query but still lists them in + // X-Amz-SignedHeaders, and R2 then hashes the missing header as empty — which + // makes the signature mismatch (S3 tolerates it, R2 does not). + const url = new URL(buildObjectUrl(getS3BucketName(), s3Url)) + url.searchParams.set('X-Amz-Expires', String(expiresIn)) + const signedRequest = await client.sign(url.toString(), { method: 'GET', - headers: { 'X-Amz-Expires': String(expiresIn) }, aws: { signQuery: true }, }) return signedRequest.url @@ -170,13 +174,13 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn await createUploadUrlStatus(key) const client = getAwsClient() - const url = buildObjectUrl(getS3BucketName(), key) - const signedRequest = await client.sign(url, { + // Same as the GET path above: keep X-Amz-Expires in the query, and do not + // sign Content-Type — leaving it unsigned means the client may send any value + // without breaking the signature. + const url = new URL(buildObjectUrl(getS3BucketName(), key)) + url.searchParams.set('X-Amz-Expires', String(expiresIn)) + const signedRequest = await client.sign(url.toString(), { method: 'PUT', - headers: { - 'Content-Type': mimeType, - 'X-Amz-Expires': String(expiresIn), - }, aws: { signQuery: true }, }) @@ -193,15 +197,22 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn // return decodeURIComponent(rawKey); // } -// New function (excludes bucket name) +/** + * Normalise an image reference to the bucket-relative object key that SKUs store. + * + * Accepts either a full (presigned) URL or an already-extracted key, so callers + * can send whichever they have. Drops the query string, then keeps the last two + * path segments: + * + * https://.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-... + * -> product-images/1.jpg + * product-images/1.jpg + * -> product-images/1.jpg + */ export function extractKeyFromPresignedUrl(url: string): string { - const u = new URL(url) - const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash - const decodedKey = decodeURIComponent(rawKey) - // Remove bucket prefix - const parts = decodedKey.split('/') - parts.shift() // Remove bucket name - return parts.join('/') + const withoutQuery = url.split('?')[0] + const parts = withoutQuery.split('/').filter(Boolean) + return decodeURIComponent(parts.slice(-2).join('/')) } export async function claimUploadUrl(url: string): Promise { diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 7018961..57b51a7 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -260,8 +260,6 @@ export const productRouter = router({ await deleteImageUtil({ keys: deletedImageKeys }) } - const allUploadUrls: string[] = skus.flatMap((sku) => sku.images) - const skuInputs = skus.map((sku) => ({ id: sku.id, name: sku.name ?? null, diff --git a/packages/ui/src/components/ImageUploaderNeo.tsx b/packages/ui/src/components/ImageUploaderNeo.tsx index eb5e5d7..37201f9 100644 --- a/packages/ui/src/components/ImageUploaderNeo.tsx +++ b/packages/ui/src/components/ImageUploaderNeo.tsx @@ -11,11 +11,12 @@ import usePickImage from './use-pick-image' export type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from '@packages/shared' -const ImageUploaderNeo: React.FC = ({ +const ImageUploaderNeo: React.FC = ({ images, onImageAdd, onImageRemove, allowMultiple = true, + testID, }) => { const totalImageCount = images.length @@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC = ({ ))} = 1} onPress={handlePickImage} style={tw`w-1/3 px-1 mb-2`}