194 lines
8.5 KiB
Diff
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`}
|