Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions roundabout/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
8 changes: 5 additions & 3 deletions roundabout/functions/redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand why this config is necessary?

})

let response
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 10 additions & 5 deletions roundabout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 })
}
}
}
Expand Down
138 changes: 135 additions & 3 deletions roundabout/test/index.test.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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`)
]
}
})
Expand Down Expand Up @@ -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!' }),
Expand Down
Loading