This commit is contained in:
shafi54 2026-09-15 22:34:20 +05:30
parent 15342a09c2
commit f6bb071d7c
6 changed files with 59 additions and 26 deletions

View file

@ -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 {

View file

@ -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,

View file

@ -44,6 +44,8 @@ export function useUploadToObjectStorage() {
});
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}`);
}

View file

@ -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> {

View file

@ -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,

View file

@ -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`}