diff --git a/src/client.ts b/src/client.ts index 97563c8..619f8be 100644 --- a/src/client.ts +++ b/src/client.ts @@ -126,8 +126,8 @@ export class Client { apiHeaders[METADATA_HEADER_EXTERNAL] = encodedMetadata } - // HEAD requests are implemented directly in the Netlify API. - if (method === HTTPMethod.HEAD) { + // HEAD and DELETE requests are implemented directly in the Netlify API. + if (method === HTTPMethod.HEAD || method === HTTPMethod.DELETE) { return { headers: apiHeaders, url: url.toString(), diff --git a/src/main.test.ts b/src/main.test.ts index 224238e..8e4a0ec 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -163,6 +163,56 @@ describe('get', () => { expect(mockStore.fulfilled).toBeTruthy() }) + + test('Reads from a store with a legacy namespace', async () => { + const mockStore = new MockFetch() + .get({ + headers: { accept: 'application/json;type=signed-url', authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/oldie/${key}`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + .get({ + headers: { accept: 'application/json;type=signed-url', authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/oldie/${key}`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + .get({ + headers: { accept: 'application/json;type=signed-url', authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/oldie/${complexKey}`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + + globalThis.fetch = mockStore.fetch + + const blobs = getStore({ + name: 'netlify-internal/legacy-namespace/oldie', + token: apiToken, + siteID, + }) + + const string = await blobs.get(key) + expect(string).toBe(value) + + const stream = await blobs.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + + const string2 = await blobs.get(complexKey) + expect(string2).toBe(value) + + expect(mockStore.fulfilled).toBeTruthy() + }) }) describe('With edge credentials', () => { @@ -1057,22 +1107,14 @@ describe('delete', () => { const mockStore = new MockFetch() .delete({ headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), + response: new Response(null, { status: 204 }), url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${key}`, }) - .delete({ - response: new Response(null), - url: signedURL, - }) .delete({ headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), + response: new Response(null, { status: 204 }), url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${complexKey}`, }) - .delete({ - response: new Response(null), - url: signedURL, - }) globalThis.fetch = mockStore.fetch @@ -1089,16 +1131,11 @@ describe('delete', () => { }) test('Does not throw when the blob does not exist', async () => { - const mockStore = new MockFetch() - .delete({ - headers: { authorization: `Bearer ${apiToken}` }, - response: new Response(JSON.stringify({ url: signedURL })), - url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${key}`, - }) - .delete({ - response: new Response(null, { status: 404 }), - url: signedURL, - }) + const mockStore = new MockFetch().delete({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(null, { status: 404 }), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:production/${key}`, + }) globalThis.fetch = mockStore.fetch @@ -1128,7 +1165,7 @@ describe('delete', () => { siteID, }) - expect(async () => await blobs.delete(key)).rejects.toThrowError( + await expect(async () => await blobs.delete(key)).rejects.toThrowError( `Netlify Blobs has generated an internal error: 401 response`, ) expect(mockStore.fulfilled).toBeTruthy() diff --git a/src/server.ts b/src/server.ts index 4570ffe..d55c2f2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,8 @@ import { HTTPMethod } from './types.ts' import { isNodeError, Logger } from './util.ts' const API_URL_PATH = /\/api\/v1\/blobs\/(?[^/]+)\/(?[^/]+)\/?(?[^?]*)/ +const LEGACY_API_URL_PATH = /\/api\/v1\/sites\/(?[^/]+)\/blobs\/?(?[^?]*)/ +const LEGACY_DEFAULT_STORE = 'production' export enum Operation { DELETE = 'delete', @@ -99,11 +101,11 @@ export class BlobsServer { async delete(req: http.IncomingMessage, res: http.ServerResponse) { const apiMatch = this.parseAPIRequest(req) - if (apiMatch) { + if (apiMatch?.useSignedURL) { return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })) } - const url = new URL(req.url ?? '', this.address) + const url = new URL(apiMatch?.url ?? req.url ?? '', this.address) const { dataPath, key, metadataPath } = this.getLocalPaths(url) if (!dataPath || !key) { @@ -390,22 +392,42 @@ export class BlobsServer { const apiURLMatch = req.url.match(API_URL_PATH) - if (!apiURLMatch) { - return null + if (apiURLMatch) { + const key = apiURLMatch.groups?.key + const siteID = apiURLMatch.groups?.site_id as string + const storeName = apiURLMatch.groups?.store_name as string + const urlPath = [siteID, storeName, key].filter(Boolean) as string[] + const url = new URL(`/${urlPath.join('/')}?signature=${this.tokenHash}`, this.address) + + return { + key, + siteID, + storeName, + url, + useSignedURL: req.headers.accept === 'application/json;type=signed-url', + } } - const key = apiURLMatch.groups?.key - const siteID = apiURLMatch.groups?.site_id as string - const storeName = apiURLMatch.groups?.store_name as string - const urlPath = [siteID, storeName, key].filter(Boolean) as string[] - const url = new URL(`/${urlPath.join('/')}?signature=${this.tokenHash}`, this.address) + const legacyAPIURLMatch = req.url.match(LEGACY_API_URL_PATH) - return { - key, - siteID, - storeName, - url, + if (legacyAPIURLMatch) { + const fullURL = new URL(req.url, this.address) + const storeName = fullURL.searchParams.get('context') ?? LEGACY_DEFAULT_STORE + const key = legacyAPIURLMatch.groups?.key + const siteID = legacyAPIURLMatch.groups?.site_id as string + const urlPath = [siteID, storeName, key].filter(Boolean) as string[] + const url = new URL(`/${urlPath.join('/')}?signature=${this.tokenHash}`, this.address) + + return { + key, + siteID, + storeName, + url, + useSignedURL: true, + } } + + return null } sendResponse(req: http.IncomingMessage, res: http.ServerResponse, status: number, body?: string) { diff --git a/src/store.ts b/src/store.ts index bccced4..430f028 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,6 +8,7 @@ import { BlobInput, HTTPMethod } from './types.ts' import { BlobsInternalError, collectIterator } from './util.ts' export const DEPLOY_STORE_PREFIX = 'deploy:' +export const LEGACY_STORE_INTERNAL_PREFIX = 'netlify-internal/legacy-namespace/' export const SITE_STORE_PREFIX = 'site:' interface BaseStoreOptions { @@ -76,6 +77,12 @@ export class Store { Store.validateDeployID(options.deployID) this.name = DEPLOY_STORE_PREFIX + options.deployID + } else if (options.name.startsWith(LEGACY_STORE_INTERNAL_PREFIX)) { + const storeName = options.name.slice(LEGACY_STORE_INTERNAL_PREFIX.length) + + Store.validateStoreName(storeName) + + this.name = storeName } else { Store.validateStoreName(options.name)