freshyo/image-upload.diff
2026-09-15 22:26:59 +05:30

194 lines
8.5 KiB
Diff

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://<acct>.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://<acct>.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(); // <Error><Code>…</Code></Error>
+ 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://<account>.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<void> {
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<ImageUploaderNeoProps> = ({
+const ImageUploaderNeo: React.FC<ImageUploaderNeoProps & { testID?: string }> = ({
images,
onImageAdd,
onImageRemove,
allowMultiple = true,
+ testID,
}) => {
const totalImageCount = images.length
@@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
</View>
))}
<MyTouchableOpacity
+ testID={testID}
disabled={!allowMultiple && totalImageCount >= 1}
onPress={handlePickImage}
style={tw`w-1/3 px-1 mb-2`}