Skip to content

Commit

Permalink
feat: allow internal access to legacy stores (#152)
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardoboucas committed Mar 6, 2024
1 parent 8820cc8 commit 1b712fe
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 37 deletions.
4 changes: 2 additions & 2 deletions src/client.ts
Expand Up @@ -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(),
Expand Down
79 changes: 58 additions & 21 deletions src/main.test.ts
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down
50 changes: 36 additions & 14 deletions src/server.ts
Expand Up @@ -13,6 +13,8 @@ import { HTTPMethod } from './types.ts'
import { isNodeError, Logger } from './util.ts'

const API_URL_PATH = /\/api\/v1\/blobs\/(?<site_id>[^/]+)\/(?<store_name>[^/]+)\/?(?<key>[^?]*)/
const LEGACY_API_URL_PATH = /\/api\/v1\/sites\/(?<site_id>[^/]+)\/blobs\/?(?<key>[^?]*)/
const LEGACY_DEFAULT_STORE = 'production'

export enum Operation {
DELETE = 'delete',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions src/store.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down

0 comments on commit 1b712fe

Please sign in to comment.