diff --git a/roundabout/constants.js b/roundabout/constants.js index ad9b6459..83172608 100644 --- a/roundabout/constants.js +++ b/roundabout/constants.js @@ -14,5 +14,5 @@ export const PIECE_V1_MULTIHASH = 0x10_12 /** https://github.com/multiformats/multicodec/pull/331/files */ export const PIECE_V2_MULTIHASH = 0x10_11 -export const CARPARK_DOMAIN = - `carpark-${process.env.SST_STAGE ?? 'dev'}-0.r2.w3s.link` +export const CARPARK_DOMAIN_PATTERN = + new RegExp(`carpark-${process.env.SST_STAGE ?? 'dev'}-\\d+\\.r2\\.w3s\\.link`) diff --git a/roundabout/functions/redirect.js b/roundabout/functions/redirect.js index 011aa4bf..42fc0bfd 100644 --- a/roundabout/functions/redirect.js +++ b/roundabout/functions/redirect.js @@ -50,11 +50,12 @@ export async function redirectGet(request) { indexingService = new Client({ serviceURL: url }) } - const locateContent = contentLocationResolver({ + const locateContent = contentLocationResolver({ bucket: getEnv().BUCKET_NAME, s3Client: getBucketClient(), expiresIn, - indexingService + indexingService, + extractCarparkBucketFromUrl: process.env.SST_STAGE === 'prod' }) let response @@ -156,7 +157,8 @@ export async function redirectPieceGet(request) { bucket: getEnv().BUCKET_NAME, s3Client: getBucketClient(), expiresIn, - indexingService + indexingService, + extractCarparkBucketFromUrl: process.env.SST_STAGE === 'prod' }) return resolvePiece(cid, locateContent, indexingService) diff --git a/roundabout/index.js b/roundabout/index.js index 5ab66042..42c7051e 100644 --- a/roundabout/index.js +++ b/roundabout/index.js @@ -9,7 +9,7 @@ import * as Digest from 'multiformats/hashes/digest' * @import { S3Client } from '@aws-sdk/client-s3' * @import { RequestPresigningArguments } from '@smithy/types' */ -import { RAW_CODE, CARPARK_DOMAIN } from './constants.js' +import { RAW_CODE, CARPARK_DOMAIN_PATTERN } from './constants.js' /** * @param {S3Client} s3Client @@ -49,8 +49,9 @@ export function getSigner (s3Client, bucketName) { * @param {string} config.bucket * @param {number} config.expiresIn * @param {IndexingServiceQueryClient} config.indexingService + * @param {boolean} [config.extractCarparkBucketFromUrl] - when true, derive the signing bucket from the carpark URL hostname (for envs with multiple carpark buckets) */ -export function contentLocationResolver ({ s3Client, bucket, expiresIn, indexingService }) { +export function contentLocationResolver ({ s3Client, bucket, expiresIn, indexingService, extractCarparkBucketFromUrl = false }) { const signer = getSigner(s3Client, bucket) /** * @param {UnknownLink} cid @@ -77,9 +78,13 @@ export function contentLocationResolver ({ s3Client, bucket, expiresIn, indexing locations.push(...c.location) for (const url of c.location) { // if location is a known carpark URI then return a signed URL - if (url.includes(CARPARK_DOMAIN)) { - const blobKey = new URL(url).pathname.slice(1) - return signer.getUrl(blobKey, { expiresIn }) + const parsedUrl = new URL(url) + if (CARPARK_DOMAIN_PATTERN.test(parsedUrl.hostname)) { + const blobKey = parsedUrl.pathname.slice(1) + const urlSigner = extractCarparkBucketFromUrl + ? getSigner(s3Client, parsedUrl.hostname.split('.')[0]) + : signer + return urlSigner.getUrl(blobKey, { expiresIn }) } } } diff --git a/roundabout/test/index.test.js b/roundabout/test/index.test.js index 06be604d..fff69191 100644 --- a/roundabout/test/index.test.js +++ b/roundabout/test/index.test.js @@ -1,6 +1,6 @@ import { test } from './helpers/context.js' -import { PutObjectCommand } from '@aws-sdk/client-s3' +import { PutObjectCommand, CreateBucketCommand } from '@aws-sdk/client-s3' import { encode } from 'multiformats/block' import { CID } from 'multiformats/cid' import { base58btc } from 'multiformats/bases/base58' @@ -14,7 +14,7 @@ import { Client } from '@storacha/indexing-service-client' import * as QueryResult from '@storacha/indexing-service-client/query-result' import * as Claim from '@storacha/indexing-service-client/claim' import { Assert } from '@storacha/capabilities' -import { RAW_CODE, CARPARK_DOMAIN } from '../constants.js' +import { RAW_CODE, CARPARK_DOMAIN_PATTERN } from '../constants.js' import { getSigner, contentLocationResolver } from '../index.js' import { parseQueryStringParameters, @@ -26,6 +26,10 @@ import { createS3, createBucket } from './helpers/resources.js' /** @import { URI } from '@ucanto/interface' */ +// default carpark domains for the test environment (SST_STAGE is undefined → 'dev') +const TEST_CARPARK_DOMAIN_0 = 'carpark-dev-0.r2.w3s.link' +const TEST_CARPARK_DOMAIN_1 = 'carpark-dev-1.r2.w3s.link' + test.before(async t => { const { client } = await createS3({ port: 9000 }) t.context.s3Client = client @@ -72,7 +76,7 @@ test('can create signed url for Blob in bucket and get it', async t => { content: blobCid, location: [ /** @type {URI} */ - (`http://${CARPARK_DOMAIN}/${encodedMultihash}/${encodedMultihash}.blob`) + (`http://${TEST_CARPARK_DOMAIN_0}/${encodedMultihash}/${encodedMultihash}.blob`) ] } }) @@ -186,6 +190,134 @@ test('fails to parse expires query parameter when not acceptable value', t => { t.throws(() => parseQueryStringParameters(queryParamsSmaller)) }) +test('CARPARK_DOMAIN_PATTERN matches any numbered carpark bucket for current stage', t => { + t.truthy(CARPARK_DOMAIN_PATTERN.test('carpark-dev-0.r2.w3s.link')) + t.truthy(CARPARK_DOMAIN_PATTERN.test('carpark-dev-1.r2.w3s.link')) + t.falsy(CARPARK_DOMAIN_PATTERN.test('carpark-prod-0.r2.w3s.link')) + t.falsy(CARPARK_DOMAIN_PATTERN.test('other-dev-0.r2.w3s.link')) + t.falsy(CARPARK_DOMAIN_PATTERN.test('carpark-dev-.r2.w3s.link')) +}) + +test('resolves blob from secondary carpark bucket when extractCarparkBucketFromUrl is true', async t => { + await t.context.s3Client.send(new CreateBucketCommand({ Bucket: 'carpark-dev-1' })) + const blobCid = await putBlobToBucket(t.context.s3Client, 'carpark-dev-1') + const encodedMultihash = base58btc.encode(blobCid.multihash.bytes) + const expiresIn = 3 * 24 * 60 * 60 + + const configuredBucket = await createBucket(t.context.s3Client) + + const alice = await ed25519.generate() + const space = await ed25519.generate() + + const site = await Assert.location.delegate({ + issuer: alice, + audience: space, + with: alice.did(), + nb: { + content: blobCid, + location: [ + /** @type {URI} */ + (`http://${TEST_CARPARK_DOMAIN_1}/${encodedMultihash}/${encodedMultihash}.blob`) + ] + } + }) + + const blocks = new Map() + for (const b of site.export()) { + blocks.set(b.cid.toString(), b) + } + + const result = await QueryResult.from({ + claims: [Claim.view({ root: site.cid, blocks })] + }) + if (result.error) return t.fail(result.error.message) + + const queryArchiveRes = await QueryResult.archive(result.ok) + if (queryArchiveRes.error) return t.fail(queryArchiveRes.error.message) + + const indexingService = new Client({ + fetch: async () => new Response(/** @type {BodyInit} */ (queryArchiveRes.ok)) + }) + + const locateContent = contentLocationResolver({ + bucket: configuredBucket, + s3Client: t.context.s3Client, + expiresIn, + indexingService, + extractCarparkBucketFromUrl: true + }) + + const signedUrl = await locateContent(blobCid) + if (!signedUrl) return t.fail('presigned url must be received') + + t.truthy(signedUrl.includes('carpark-dev-1')) + t.truthy(signedUrl.includes(`${encodedMultihash}/${encodedMultihash}.blob`)) + + const fetchResponse = await fetch(signedUrl) + t.assert(fetchResponse.ok) +}) + +test('uses configured bucket when extractCarparkBucketFromUrl is false', async t => { + await t.context.s3Client.send(new CreateBucketCommand({ Bucket: 'carpark-dev-2' })) + const blobCid = await putBlobToBucket(t.context.s3Client, 'carpark-dev-2') + const encodedMultihash = base58btc.encode(blobCid.multihash.bytes) + const expiresIn = 3 * 24 * 60 * 60 + + const configuredBucket = await createBucket(t.context.s3Client) + + const alice = await ed25519.generate() + const space = await ed25519.generate() + + const site = await Assert.location.delegate({ + issuer: alice, + audience: space, + with: alice.did(), + nb: { + content: blobCid, + location: [ + /** @type {URI} */ + (`http://carpark-dev-2.r2.w3s.link/${encodedMultihash}/${encodedMultihash}.blob`) + ] + } + }) + + const blocks = new Map() + for (const b of site.export()) { + blocks.set(b.cid.toString(), b) + } + + const result = await QueryResult.from({ + claims: [Claim.view({ root: site.cid, blocks })] + }) + if (result.error) return t.fail(result.error.message) + + const queryArchiveRes = await QueryResult.archive(result.ok) + if (queryArchiveRes.error) return t.fail(queryArchiveRes.error.message) + + const indexingService = new Client({ + fetch: async () => new Response(/** @type {BodyInit} */ (queryArchiveRes.ok)) + }) + + const locateContent = contentLocationResolver({ + bucket: configuredBucket, + s3Client: t.context.s3Client, + expiresIn, + indexingService + // extractCarparkBucketFromUrl defaults to false + }) + + const signedUrl = await locateContent(blobCid) + if (!signedUrl) return t.fail('presigned url must be received') + + // signed URL targets the configured bucket, not the secondary one from the claim URL + t.truthy(signedUrl.includes(configuredBucket)) + t.falsy(signedUrl.includes('carpark-dev-2')) + + // blob is in carpark-dev-2, not in configuredBucket → 404 + const fetchResponse = await fetch(signedUrl) + t.is(fetchResponse.status, 404) +}) + async function getContent () { const id = await encode({ value: pb.prepare({ Data: 'a red car on the street!' }),