UNPKG

hypercore-fetch

Version:

Implementation of Fetch that uses the Dat SDK for loading p2p content

665 lines (504 loc) 22.6 kB
/* global FormData, Blob */ import * as SDK from 'hyper-sdk' import test from 'tape' import createEventSource from '@rangermauve/fetch-event-source' import { once } from 'events' import os from 'os' import { join } from 'path' import { rm } from 'fs/promises' import makeHyperFetch from './index.js' const SAMPLE_CONTENT = 'Hello World' const DNS_DOMAIN = 'blog.mauve.moe' let count = 0 function next () { return count++ } async function nextURL (t) { const createResponse = await fetch(`hyper://localhost/?key=example${next()}`, { method: 'post' }) await checkResponse(createResponse, t, 'Created new drive') const created = await createResponse.text() return created } const tmpSuffix = Math.random().toString().slice(3, 8) const tmp = join(os.tmpdir(), `hp-ftch-${tmpSuffix}`) const sdk1 = await SDK.create({ storage: join(tmp, 'sdk1') }) const sdk2 = await SDK.create({ storage: join(tmp, 'sdk2') }) const fetch = await makeHyperFetch({ sdk: sdk1, writable: true }) const fetch2 = await makeHyperFetch({ sdk: sdk2, writable: true }) test.onFinish(async () => { await Promise.all([ sdk1.close(), sdk2.close() ]) await rm(tmp, { recursive: true }) }) test('Quick check', async (t) => { const createResponse = await fetch(`hyper://localhost/?key=example${next()}`, { method: 'post' }) await checkResponse(createResponse, t, 'Created new drive') const created = await createResponse.text() t.ok(created.startsWith('hyper://'), 'Created drive has hyper URL') const existsResponse = await fetch(created) await checkResponse(existsResponse, t) t.deepEqual(await existsResponse.json(), [], 'Empty dir on create') const uploadLocation = new URL('./example .txt', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const uploadedContentResponse = await fetch(uploadLocation) await checkResponse(uploadedContentResponse, t) const content = await uploadedContentResponse.text() const contentType = uploadedContentResponse.headers.get('Content-Type') const contentLink = uploadedContentResponse.headers.get('Link') t.match(contentLink, /^<hyper:\/\/[0-9a-z]{52}\/example%20.txt>; rel="canonical"$/, 'Link header includes both public key and path.') t.equal(contentType, 'text/plain; charset=utf-8', 'Content got expected mime type') t.equal(content, SAMPLE_CONTENT, 'Got uploaded content back out') const dirResponse = await fetch2(created) await checkResponse(dirResponse, t) t.deepEqual(await dirResponse.json(), ['example .txt'], 'File got added') }) test('GET full url for created keys', async (t) => { const keyURL = `hyper://localhost/?key=example${next()}` const createResponse = await fetch(keyURL, { method: 'post' }) await checkResponse(createResponse, t, 'Able to create drive') const createdURL = await createResponse.text() t.ok(createdURL.startsWith('hyper://'), 'Got new hyper:// URL') const nowExistingResponse = await fetch(keyURL) await checkResponse(nowExistingResponse, t, 'GET no longer fails on create') const existingURL = await nowExistingResponse.text() t.equal(existingURL, createdURL, 'URL same as in initial create') }) test('HEAD request', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./example.txt', created) await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) const headResponse = await fetch(uploadLocation, { method: 'head' }) await checkResponse(headResponse, t, 'Able to load HEAD') const headersEtag = headResponse.headers.get('Etag') const headersContentType = headResponse.headers.get('Content-Type') const headersContentLength = headResponse.headers.get('Content-Length') const headersAcceptRanges = headResponse.headers.get('Accept-Ranges') const headersLastModified = headResponse.headers.get('Last-Modified') const headersLink = headResponse.headers.get('Link') t.equal(headResponse.status, 204, 'Response had expected status') // Version at which the file was added t.equal(headersEtag, '2', 'Headers got expected etag') t.equal(headersContentType, 'text/plain; charset=utf-8', 'Headers got expected mime type') t.ok(headersContentLength, "Headers have 'Content-Length' set.") t.ok(headersLastModified, "Headers have 'Last-Modified' set.") t.equal(headersAcceptRanges, 'bytes') t.match(headersLink, /^<hyper:\/\/[0-9a-z]{52}\/example.txt>; rel="canonical"$/, 'Link header includes both public key and path.') }) test('PUT file', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./example.txt', created) const fakeDate = new Date(Date.parse(0)).toUTCString() const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT, headers: { 'Last-Modified': fakeDate } }) await checkResponse(uploadResponse, t, 'upload successful') const uploadedContentResponse = await fetch(uploadLocation) await checkResponse(uploadedContentResponse, t, 'able to load content') const content = await uploadedContentResponse.text() const contentType = uploadedContentResponse.headers.get('Content-Type') const lastModified = uploadedContentResponse.headers.get('Last-Modified') t.equal(contentType, 'text/plain; charset=utf-8', 'Content got expected mime type') t.equal(content, SAMPLE_CONTENT, 'Got uploaded content back out') t.equal(lastModified, fakeDate, 'Last-Modified header was set to value of Date header') }) test('PUT FormData', async (t) => { const created = await nextURL(t) const formData = new FormData() formData.append('file', new Blob([SAMPLE_CONTENT]), 'example.txt') formData.append('file', new Blob([SAMPLE_CONTENT]), 'example2.txt') const uploadedResponse = await fetch(created, { method: 'put', body: formData }) await checkResponse(uploadedResponse, t) const file2URL = new URL('/example2.txt', created) const file2Response = await fetch(file2URL) await checkResponse(file2Response, t) const file2Content = await file2Response.text() t.equal(file2Content, SAMPLE_CONTENT, 'file contents got uploaded') const listDirRequest = await fetch(created) await checkResponse(listDirRequest, t) const entries = await listDirRequest.json() t.deepEqual(entries, ['example.txt', 'example2.txt'], 'new files are listed') }) test('PUT into new directory', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./subfolder/example.txt', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const uploadedContentResponse = await fetch(uploadLocation) await checkResponse(uploadedContentResponse, t) const content = await uploadedContentResponse.text() const contentType = uploadedContentResponse.headers.get('Content-Type') t.equal(contentType, 'text/plain; charset=utf-8', 'Content got expected mime type') t.equal(content, SAMPLE_CONTENT, 'Got uploaded content back out') const topDirResponse = await fetch(created) await checkResponse(topDirResponse, t) const topDirEntries = await topDirResponse.json() t.deepEqual(topDirEntries, ['subfolder/'], 'subdirectory is listed') const subDir = new URL('./subfolder/', created) const subDirResponse = await fetch(subDir) await checkResponse(subDirResponse, t) const subDirEntries = await subDirResponse.json() t.deepEqual(subDirEntries, ['example.txt'], 'new file is listed') }) test('PUT to overwrite a file', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./example.txt', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const SHORTER_CONTENT = 'Hello' const overWriteResponse = await fetch(uploadLocation, { method: 'put', body: SHORTER_CONTENT }) await checkResponse(overWriteResponse, t) const uploadedContentResponse = await fetch(uploadLocation) await checkResponse(uploadedContentResponse, t) const content = await uploadedContentResponse.text() const contentType = uploadedContentResponse.headers.get('Content-Type') t.equal(contentType, 'text/plain; charset=utf-8', 'Content got expected mime type') t.equal(content, SHORTER_CONTENT, 'Got uploaded content back out') }) test('DELETE a file', async (t) => { const created = await nextURL(t) const formData = new FormData() formData.append('file', new Blob([SAMPLE_CONTENT]), 'example.txt') formData.append('file', new Blob([SAMPLE_CONTENT]), 'example2.txt') const uploadedResponse = await fetch(created, { method: 'put', body: formData }) await checkResponse(uploadedResponse, t) const file2URL = new URL('/example2.txt', created) const deleteResponse = await fetch(file2URL, { method: 'delete' }) await checkResponse(deleteResponse, t, 'Able to DELETE') const dirResponse = await fetch(created) await checkResponse(dirResponse, t) t.deepEqual(await dirResponse.json(), ['example.txt'], 'Only one file remains') }) test('DELETE a directory', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./subfolder/example.txt', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const deleteResponse = await fetch(uploadLocation, { method: 'delete' }) await checkResponse(deleteResponse, t, 'Able to DELETE') const listDirRequest = await fetch(created) await checkResponse(listDirRequest, t) const entries = await listDirRequest.json() t.deepEqual(entries, [], 'subfolder got deleted') }) test.skip('DELETE a drive from storage', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./subfolder/example.txt', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const purgeResponse = await fetch(created, { method: 'delete' }) await checkResponse(purgeResponse, t, 'Able to purge') const listDirRequest = await fetch(created) t.notOk(listDirRequest.ok, 'Error when trying to read after purge') }) test('Read index.html', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./index.html', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const uploadedContentResponse = await fetch(uploadLocation) await checkResponse(uploadedContentResponse, t) const content = await uploadedContentResponse.text() const contentType = uploadedContentResponse.headers.get('Content-Type') t.equal(contentType, 'text/html; charset=utf-8', 'got HTML mime type') t.equal(content, SAMPLE_CONTENT, 'loaded index.html content') }) test('Ignore index.html with noResolve', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./index.html', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const noResolve = created + '?noResolve' const listDirRequest = await fetch(noResolve) await checkResponse(listDirRequest, t) const entries = await listDirRequest.json() t.deepEqual(entries, ['index.html'], 'able to list index.html') }) test('Ensure that noResolve works with file paths', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./example.txt', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const noResolve = uploadLocation.href + '?noResolve' const getRequest = await fetch(noResolve) await checkResponse(getRequest, t) const headRequest = await fetch(noResolve, { method: 'HEAD' }) await checkResponse(headRequest, t) }) test('Render index.gmi', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./index.gmi', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const uploadedContentResponse = await fetch(created) await checkResponse(uploadedContentResponse, t) const content = await uploadedContentResponse.text() const contentType = uploadedContentResponse.headers.get('Content-Type') t.equal(contentType, 'text/gemini; charset=utf-8', 'got HTML mime type') t.equal(content, SAMPLE_CONTENT, 'loaded index.html content') }) test('Read directory as HTML', async (t) => { const created = await nextURL(t) const formData = new FormData() formData.append('file', new Blob([SAMPLE_CONTENT]), 'example.txt') formData.append('file', new Blob([SAMPLE_CONTENT]), 'example2.txt') const uploadedResponse = await fetch(created, { method: 'put', body: formData }) await checkResponse(uploadedResponse, t) const listDirRequest = await fetch(created, { headers: { Accept: 'text/html' } }) await checkResponse(listDirRequest, t, 'Able to list HTML') const html = await listDirRequest.text() t.equal(listDirRequest.headers.get('Content-Type'), 'text/html; charset=utf-8', 'Returned HTML in mime type') t.ok(html.includes('<title'), 'Listing has title') t.ok(html.includes('./example.txt'), 'Listing has link to file') }) test('Resolve pretty markdown URLs', async (t) => { const created = await nextURL(t) const uploadLocation = new URL('./example.md', created) const uploadResponse = await fetch(uploadLocation, { method: 'put', body: SAMPLE_CONTENT }) await checkResponse(uploadResponse, t) const resolvedLocation = new URL('/example', created) const uploadedContentResponse = await fetch(resolvedLocation) await checkResponse(uploadedContentResponse, t, 'able to load content') const content = await uploadedContentResponse.text() const contentType = uploadedContentResponse.headers.get('Content-Type') t.equal(content, SAMPLE_CONTENT, 'Got original content out') t.equal(contentType, 'text/markdown; charset=utf-8', 'Got markdown mime type') }) test('EventSource extension messages', async (t) => { const domain = await nextURL(t) const extensionURL = `${domain}$/extensions/example` const extensionListURL = `${domain}$/extensions/` // Load up extension message on peer 1 const extensionLoadResponse1 = await fetch(extensionURL) await checkResponse(extensionLoadResponse1, t) // Load up extension message on peer 2 const extensionLoadResponse2 = await fetch2(extensionURL) await checkResponse(extensionLoadResponse2, t) const extensionListRequest = await fetch(extensionListURL) const extensionList = await extensionListRequest.json() // Extension list will always be alphabetically sorted t.deepEqual(extensionList, ['example'], 'Got expected list of extensions') const peerResponse1 = await fetch(extensionURL) const peerList1 = await peerResponse1.json() t.equal(peerList1.length, 1, 'Got one peer for extension message on peer1') const peerResponse2 = await fetch2(extensionURL) const peerList2 = await peerResponse2.json() t.equal(peerList2.length, 1, 'Got one peer for extension message on peer2') const { EventSource } = createEventSource(fetch) const source = new EventSource(extensionListURL) await Promise.race([ once(source, 'open'), once(source, 'error').then(([e]) => { throw e }) ]) const toRead = Promise.race([ once(source, 'example'), once(source, 'error').then(([e]) => { throw e }) ]) const broadcastRequest = await fetch2(extensionURL, { method: 'POST', body: SAMPLE_CONTENT }) t.ok(broadcastRequest.ok, 'Able to broadcast to peers') const [event] = await toRead const { type, data, lastEventId } = event t.equal(data, SAMPLE_CONTENT, 'Got data from event') t.equal(type, 'example', 'Event got set to extension message name') t.ok(lastEventId, 'Event contained peer ID') }) test('Resolve DNS', async (t) => { const loadResponse = await fetch(`hyper://${DNS_DOMAIN}/?noResolve`) const entries = await loadResponse.json() t.ok(entries.length, 'Loaded contents with some files present') const rawLink = loadResponse.headers.get('Link').match(/<(.+)>/)[1] const loadRawURLResponse = await fetch(rawLink + '?noResolve') const rawLinkEntries = await loadRawURLResponse.json() t.deepEqual(rawLinkEntries, entries, 'Raw link resolves to same content as DNS domain.') }) test('Doing a `GET` on an invalid domain/public key should cause an error', async (t) => { const invalidDomainResponse = await fetch('hyper://example/') t.notOk(invalidDomainResponse.ok, 'Response errored out due to invalid domain') const invalidPublicKeyResponse = await fetch('hyper://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/') t.notOk(invalidPublicKeyResponse.ok, 'Response errored out due to invalid public key') t.equal(invalidPublicKeyResponse.status, 404, 'Invalid public key should error') }) test('Old versions in VERSION folder', async (t) => { const created = await nextURL(t) const fileName = 'example.txt' const data1 = 'Hello World' const data2 = 'Goodbye World' const fileURL = new URL(`/${fileName}`, created) const versionFileURL = new URL(`/$/version/2/${fileName}`, created) const versionRootURL = new URL('/$/version/1/', created) await checkResponse( await fetch(fileURL, { method: 'PUT', body: data1 }), t ) await checkResponse( await fetch(fileURL, { method: 'PUT', body: data2 }), t ) const versionedFileResponse = await fetch(versionFileURL) await checkResponse(versionedFileResponse, t, 'Able to GET versioned file') const versionedFileData = await versionedFileResponse.text() t.equal(versionedFileData, data1, 'Old data got loaded') const versionedRootResponse = await fetch(versionRootURL) await checkResponse(versionedRootResponse, t, 'Able to GET versioned root') const versionedRootContents = await versionedRootResponse.json() t.deepEqual(versionedRootContents, [], 'Old root content got loaded') // PUT on old version should fail const putResponse = await fetch(versionFileURL, { method: 'PUT', body: SAMPLE_CONTENT }) if (putResponse.ok) { throw new Error('PUT old version of file should have failed') } else { t.equal(putResponse.status, 405, 'PUT old version returned status 405 Not Allowed') } // DELETE on old version should fail const deleteResponse = await fetch(versionFileURL, { method: 'delete' }) if (deleteResponse.ok) { throw new Error('DELETE old version of file should have failed') } else { t.equal(deleteResponse.status, 405, 'DELETE old version returned status 405 Not Allowed') } }) test('Handle empty string pathname', async (t) => { const created = await nextURL(t) const urlObject = new URL('', created) const urlNoTrailingSlash = urlObject.href.slice(0, -1) const versionedURLObject = new URL('/$/version/3/', created) const versionedURLNoTrailingSlash = versionedURLObject.href.slice(0, -1) // PUT const putResponse = await fetch(urlNoTrailingSlash, { method: 'PUT', body: SAMPLE_CONTENT }) if (putResponse.ok) { throw new Error('PUT file at the root directory should have failed') } else { t.pass('PUT file at root directory threw an error') } // PUT FormData const formData = new FormData() formData.append('file', new Blob([SAMPLE_CONTENT]), 'example.txt') formData.append('file', new Blob([SAMPLE_CONTENT]), 'example2.txt') await checkResponse( await fetch(urlNoTrailingSlash, { method: 'put', body: formData }), t ) // HEAD (versioned) const versionedHeadResponse = await fetch(versionedURLNoTrailingSlash, { method: 'HEAD' }) await checkResponse(versionedHeadResponse, t) t.deepEqual(versionedHeadResponse.headers.get('Etag'), '3', 'Versioned HEAD request returns correct Etag') // GET const getResponse = await fetch(urlNoTrailingSlash) await checkResponse(getResponse, t) t.deepEqual(await getResponse.json(), ['example.txt', 'example2.txt'], 'Returns directory listing') // GET (versioned) const versionedGetResponse = await fetch(versionedURLNoTrailingSlash) await checkResponse(versionedGetResponse, t) t.deepEqual(await versionedGetResponse.json(), ['example.txt', 'example2.txt'], 'Returns root directory prior to DELETE') // DELETE // await checkResponse(await fetch(urlNoTrailingSlash, { method: 'DELETE' }), t, 'Able to delete root') }) test('Return status 403 Forbidden on attempt to modify read-only hyperdrive', async (t) => { const readOnlyURL = `hyper://${DNS_DOMAIN}/new-file.txt` const putResponse = await fetch(readOnlyURL, { method: 'PUT', body: SAMPLE_CONTENT }) if (putResponse.ok) { throw new Error('PUT file to read-only drive should have failed') } else { t.equal(putResponse.status, 403, 'PUT file to read-only drive returned status 403 Forbidden') } const deleteResponse = await fetch(readOnlyURL, { method: 'DELETE' }) if (deleteResponse.ok) { throw new Error('DELETE file in read-only drive should have failed') } else { t.equal(deleteResponse.status, 403, 'DELETE file to read-only drive returned status 403 Forbidden') } }) test('Check hyperdrive writability', async (t) => { const created = await nextURL(t) const readOnlyRootDirectory = `hyper://${DNS_DOMAIN}/?noResolve` const readOnlyHeadResponse = await fetch(readOnlyRootDirectory, { method: 'HEAD' }) await checkResponse(readOnlyHeadResponse, t, 'Able to load HEAD') const readOnlyHeadersAllow = readOnlyHeadResponse.headers.get('Allow') t.equal(readOnlyHeadersAllow, 'HEAD,GET', 'Expected read-only Allows header') const writableHeadResponse = await fetch(created, { method: 'HEAD' }) await checkResponse(writableHeadResponse, t, 'Able to load HEAD') const writableHeadersAllow = writableHeadResponse.headers.get('Allow') t.equal(writableHeadersAllow, 'HEAD,GET,PUT,DELETE', 'Expected writable Allows header') }) async function checkResponse (response, t, successMessage = 'Response OK') { if (!response.ok) { const message = await response.text() t.fail(new Error(`HTTP Error ${response.status}:\n${message}`)) return false } else { t.pass(successMessage) return true } }