Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support redirects for UnixFS directories #5

Merged
merged 2 commits into from
Mar 3, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
83 changes: 76 additions & 7 deletions packages/verified-fetch/src/utils/responses.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,98 @@
export function okResponse (body?: BodyInit | null): Response {
return new Response(body, {
function setField (response: Response, name: string, value: string | boolean): void {
Object.defineProperty(response, name, {
enumerable: true,
configurable: false,
set: () => {},
get: () => value
})
}

function setType (response: Response, value: 'basic' | 'cors' | 'error' | 'opaque' | 'opaqueredirect'): void {
setField(response, 'type', value)
}

function setUrl (response: Response, value: string): void {
setField(response, 'url', value)
}

function setRedirected (response: Response): void {
setField(response, 'redirected', true)
}

export interface ResponseOptions extends ResponseInit {
redirected?: boolean
}

export function okResponse (url: string, body?: BodyInit | null, init?: ResponseOptions): Response {
const response = new Response(body, {
...(init ?? {}),
status: 200,
statusText: 'OK'
})

if (init?.redirected === true) {
setRedirected(response)
}

setType(response, 'basic')
setUrl(response, url)

return response
}

export function notSupportedResponse (body?: BodyInit | null): Response {
export function notSupportedResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
const response = new Response(body, {
...(init ?? {}),
status: 501,
statusText: 'Not Implemented'
})
response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header

setType(response, 'basic')
setUrl(response, url)

return response
}

export function notAcceptableResponse (body?: BodyInit | null): Response {
return new Response(body, {
export function notAcceptableResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
const response = new Response(body, {
...(init ?? {}),
status: 406,
statusText: 'Not Acceptable'
})

setType(response, 'basic')
setUrl(response, url)

return response
}

export function badRequestResponse (body?: BodyInit | null): Response {
return new Response(body, {
export function badRequestResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
const response = new Response(body, {
...(init ?? {}),
status: 400,
statusText: 'Bad Request'
})

setType(response, 'basic')
setUrl(response, url)

return response
}

export function movedPermanentlyResponse (url: string, location: string, init?: ResponseInit): Response {
const response = new Response(null, {
...(init ?? {}),
status: 301,
statusText: 'Moved Permanently',
headers: {
...(init?.headers ?? {}),
location
}
})

setType(response, 'basic')
setUrl(response, url)

return response
}
55 changes: 36 additions & 19 deletions packages/verified-fetch/src/verified-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
import { tarStream } from './utils/get-tar-stream.js'
import { parseResource } from './utils/parse-resource.js'
import { badRequestResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
import { walkPath } from './utils/walk-path.js'
import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
Expand Down Expand Up @@ -167,7 +167,7 @@
const buf = await this.helia.datastore.get(datastoreKey, options)
const record = DHTRecord.deserialize(buf)

const response = okResponse(record.value)
const response = okResponse(resource, record.value)
response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')

return response
Expand All @@ -177,11 +177,11 @@
* Accepts a `CID` and returns a `Response` with a body stream that is a CAR
* of the `DAG` referenced by the `CID`.
*/
private async handleCar ({ cid, options }: FetchHandlerFunctionArg): Promise<Response> {
private async handleCar ({ resource, cid, options }: FetchHandlerFunctionArg): Promise<Response> {
const c = car(this.helia)
const stream = toBrowserReadableStream(c.stream(cid, options))

const response = okResponse(stream)
const response = okResponse(resource, stream)
response.headers.set('content-type', 'application/vnd.ipld.car; version=1')

return response
Expand All @@ -191,20 +191,20 @@
* Accepts a UnixFS `CID` and returns a `.tar` file containing the file or
* directory structure referenced by the `CID`.
*/
private async handleTar ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
private async handleTar ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
if (cid.code !== dagPbCode && cid.code !== rawCode) {
return notAcceptableResponse('only UnixFS data can be returned in a TAR file')
}

const stream = toBrowserReadableStream<Uint8Array>(tarStream(`/ipfs/${cid}/${path}`, this.helia.blockstore, options))

const response = okResponse(stream)
const response = okResponse(resource, stream)
response.headers.set('content-type', 'application/x-tar')

return response
}

private async handleJson ({ cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
private async handleJson ({ resource, cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
this.log.trace('fetching %c/%s', cid, path)
const block = await this.helia.blockstore.get(cid, options)
let body: string | Uint8Array
Expand All @@ -218,19 +218,19 @@
body = ipldDagCbor.encode(obj)
} catch (err) {
this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err)
return notAcceptableResponse()
return notAcceptableResponse(resource)

Check warning on line 221 in packages/verified-fetch/src/verified-fetch.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/src/verified-fetch.ts#L221

Added line #L221 was not covered by tests
}
} else {
// skip decoding
body = block
}

const response = okResponse(body)
const response = okResponse(resource, body)
response.headers.set('content-type', accept ?? 'application/json')
return response
}

private async handleDagCbor ({ cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
private async handleDagCbor ({ resource, cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
this.log.trace('fetching %c/%s', cid, path)

const block = await this.helia.blockstore.get(cid, options)
Expand All @@ -248,7 +248,7 @@
body = ipldDagJson.encode(obj)
} catch (err) {
this.log.error('could not transform %c to application/vnd.ipld.dag-json', err)
return notAcceptableResponse()
return notAcceptableResponse(resource)

Check warning on line 251 in packages/verified-fetch/src/verified-fetch.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/src/verified-fetch.ts#L251

Added line #L251 was not covered by tests
}
} else {
try {
Expand All @@ -257,15 +257,15 @@
if (accept === 'application/json') {
this.log('could not decode DAG-CBOR as JSON-safe, but the client sent "Accept: application/json"', err)

return notAcceptableResponse()
return notAcceptableResponse(resource)
}

this.log('could not decode DAG-CBOR as JSON-safe, falling back to `application/octet-stream`', err)
body = block
}
}

const response = okResponse(body)
const response = okResponse(resource, body)

if (accept == null) {
accept = body instanceof Uint8Array ? 'application/octet-stream' : 'application/json'
Expand All @@ -276,9 +276,10 @@
return response
}

private async handleDagPb ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
private async handleDagPb ({ cid, path, resource, options }: FetchHandlerFunctionArg): Promise<Response> {
let terminalElement: UnixFSEntry | undefined
let ipfsRoots: CID[] | undefined
let redirected = false

try {
const pathDetails = await walkPath(this.helia.blockstore, `${cid.toString()}/${path}`, options)
Expand All @@ -293,6 +294,21 @@
if (terminalElement?.type === 'directory') {
const dirCid = terminalElement.cid

// https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
if (path !== '' && !path.endsWith('/')) {
if (options?.redirect === 'error') {
this.log('could not redirect to %s/ as redirect option was set to "error"', resource)
throw new TypeError('Failed to fetch')

Check warning on line 301 in packages/verified-fetch/src/verified-fetch.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/src/verified-fetch.ts#L300-L301

Added lines #L300 - L301 were not covered by tests
} else if (options?.redirect === 'manual') {
this.log('returning 301 permanent redirect to %s/', resource)
return movedPermanentlyResponse(resource, `${resource}/`)
}

// fall-through simulates following the redirect?
resource = `${resource}/`
redirected = true
}

const rootFilePath = 'index.html'
try {
this.log.trace('found directory at %c/%s, looking for index.html', cid, path)
Expand All @@ -304,7 +320,6 @@
this.log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, stat.cid)
path = rootFilePath
resolvedCID = stat.cid
// terminalElement = stat
} catch (err: any) {
this.log('error loading path %c/%s', dirCid, rootFilePath, err)
return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented')
Expand All @@ -322,7 +337,9 @@
const { stream, firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.helia.logger, {
onProgress: options?.onProgress
})
const response = okResponse(stream)
const response = okResponse(resource, stream, {
redirected
})
await this.setContentType(firstChunk, path, response)

if (ipfsRoots != null) {
Expand All @@ -332,9 +349,9 @@
return response
}

private async handleRaw ({ cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
private async handleRaw ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
const result = await this.helia.blockstore.get(cid, options)
const response = okResponse(result)
const response = okResponse(resource, result)

// if the user has specified an `Accept` header that corresponds to a raw
// type, honour that header, so for example they don't request
Expand Down Expand Up @@ -418,7 +435,7 @@
this.log('output type %s', accept)

if (acceptHeader != null && accept == null) {
return notAcceptableResponse()
return notAcceptableResponse(resource.toString())
}

let response: Response
Expand Down
78 changes: 78 additions & 0 deletions packages/verified-fetch/test/verified-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,84 @@
expect(new Uint8Array(data)).to.equalBytes(finalRootFileContent)
})

it('should return a 301 with a trailing slash when a directory is requested without a trailing slash', async () => {
const finalRootFileContent = new Uint8Array([0x01, 0x02, 0x03])

const fs = unixfs(helia)
const res = await last(fs.addAll([{
path: 'foo/index.html',
content: finalRootFileContent
}], {
wrapWithDirectory: true
}))

if (res == null) {
throw new Error('Import failed')
}

Check warning on line 149 in packages/verified-fetch/test/verified-fetch.spec.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/test/verified-fetch.spec.ts#L148-L149

Added lines #L148 - L149 were not covered by tests

const stat = await fs.stat(res.cid)
expect(stat.type).to.equal('directory')

const ipfsResponse = await verifiedFetch.fetch(`ipfs://${res.cid}/foo`, {
redirect: 'manual'
})
expect(ipfsResponse).to.be.ok()
expect(ipfsResponse.status).to.equal(301)
expect(ipfsResponse.headers.get('location')).to.equal(`ipfs://${res.cid}/foo/`)
expect(ipfsResponse.url).to.equal(`ipfs://${res.cid}/foo`)
})

it('should simulate following a redirect to a path with a slash when a directory is requested without a trailing slash', async () => {
const finalRootFileContent = new Uint8Array([0x01, 0x02, 0x03])

const fs = unixfs(helia)
const res = await last(fs.addAll([{
path: 'foo/index.html',
content: finalRootFileContent
}], {
wrapWithDirectory: true
}))

if (res == null) {
throw new Error('Import failed')
}

Check warning on line 176 in packages/verified-fetch/test/verified-fetch.spec.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/test/verified-fetch.spec.ts#L175-L176

Added lines #L175 - L176 were not covered by tests

const stat = await fs.stat(res.cid)
expect(stat.type).to.equal('directory')

const ipfsResponse = await verifiedFetch.fetch(`ipfs://${res.cid}/foo`)
expect(ipfsResponse).to.be.ok()
expect(ipfsResponse.type).to.equal('basic')
expect(ipfsResponse.status).to.equal(200)
expect(ipfsResponse.redirected).to.be.true()
expect(ipfsResponse.url).to.equal(`ipfs://${res.cid}/foo/`)
})

it('should not redirect when a directory is requested with a trailing slash', async () => {
const finalRootFileContent = new Uint8Array([0x01, 0x02, 0x03])

const fs = unixfs(helia)
const res = await last(fs.addAll([{
path: 'foo/index.html',
content: finalRootFileContent
}], {
wrapWithDirectory: true
}))

if (res == null) {
throw new Error('Import failed')
}

Check warning on line 202 in packages/verified-fetch/test/verified-fetch.spec.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/test/verified-fetch.spec.ts#L201-L202

Added lines #L201 - L202 were not covered by tests

const stat = await fs.stat(res.cid)
expect(stat.type).to.equal('directory')

const ipfsResponse = await verifiedFetch.fetch(`ipfs://${res.cid}/foo/`)
expect(ipfsResponse).to.be.ok()
expect(ipfsResponse.status).to.equal(200)
expect(ipfsResponse.redirected).to.be.false()
expect(ipfsResponse.url).to.equal(`ipfs://${res.cid}/foo/`)
})

it('should allow use as a stream', async () => {
const content = new Uint8Array([0x01, 0x02, 0x03])

Expand Down
Loading