From 4bb33bff2c89ad855888aceda2a69dfbc7e84a41 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= <mail@eduardoboucas.com>
Date: Tue, 14 Nov 2023 09:52:55 +0000
Subject: [PATCH 1/2] chore: add `BlobsServer` to README

---
 README.md        | 36 ++++++++++++++++++++++++++++++++++++
 src/list.test.ts |  2 +-
 src/main.test.ts |  2 +-
 3 files changed, 38 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index 2beb787..b6676b9 100644
--- a/README.md
+++ b/README.md
@@ -378,6 +378,42 @@ for await (const entry of store.list({ paginate: true })) {
 console.log(blobs)
 ```
 
+## Server API reference
+
+We provide a Node.js server that implements the Netlify Blobs server interface backed by the local filesystem. This is
+useful if you want to write automated tests that involve the Netlify Blobs API without interacting with a live store.
+
+The `BlobsServer` export lets you construct and initialize a server. You can then use its address to initialize a store.
+
+```ts
+import { BlobsServer, getStore } from '@netlify/blobs'
+
+// Choose any token for protecting your local server from
+// extraneous requests
+const token = 'some-token'
+
+// Create a server by providing a local directory where all
+// blobs and metadata should be persisted
+const server = new BlobsServer({
+  directory: '/path/to/blobs/directory',
+  port: 1234,
+  token,
+})
+
+await server.start()
+
+// Get a store and provide the address of the local server
+const store = getStore({
+  edgeURL: 'http://localhost:1234',
+  name: 'my-store',
+  token,
+})
+
+await store.set('my-key', 'This is a local blob')
+
+console.log(await store.get('my-key'))
+```
+
 ## Contributing
 
 Contributions are welcome! If you encounter any issues or have suggestions for improvements, please open an issue or
diff --git a/src/list.test.ts b/src/list.test.ts
index f560fef..beb3dc0 100644
--- a/src/list.test.ts
+++ b/src/list.test.ts
@@ -31,7 +31,7 @@ const siteID = '9a003659-aaaa-0000-aaaa-63d3720d8621'
 const storeName = 'mystore'
 const apiToken = 'some token'
 const edgeToken = 'some other token'
-const edgeURL = 'https://cloudfront.url'
+const edgeURL = 'https://edge.netlify'
 
 describe('list', () => {
   describe('With API credentials', () => {
diff --git a/src/main.test.ts b/src/main.test.ts
index ce5fa59..6c71934 100644
--- a/src/main.test.ts
+++ b/src/main.test.ts
@@ -38,7 +38,7 @@ const value = 'some value'
 const apiToken = 'some token'
 const signedURL = 'https://signed.url/123456789'
 const edgeToken = 'some other token'
-const edgeURL = 'https://cloudfront.url'
+const edgeURL = 'https://edge.netlify'
 
 describe('get', () => {
   describe('With API credentials', () => {

From d9df1e6da68206c45ecc9d1a1998c9b425281ccf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= <mail@eduardoboucas.com>
Date: Tue, 14 Nov 2023 10:57:04 +0000
Subject: [PATCH 2/2] feat: add support for API access in local server

---
 src/server.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++
 src/server.ts      | 38 +++++++++++++++++++++++++++++++++++---
 2 files changed, 76 insertions(+), 3 deletions(-)

diff --git a/src/server.test.ts b/src/server.test.ts
index 74b9b7d..83cd809 100644
--- a/src/server.test.ts
+++ b/src/server.test.ts
@@ -215,3 +215,44 @@ test('Lists entries', async () => {
 
   expect(parachutesSongs2.directories).toEqual([])
 })
+
+test('Supports the API access interface', async () => {
+  const directory = await tmp.dir()
+  const server = new BlobsServer({
+    directory: directory.path,
+    token,
+  })
+  const { port } = await server.start()
+  const blobs = getStore({
+    apiURL: `http://localhost:${port}`,
+    name: 'mystore',
+    token,
+    siteID,
+  })
+  const metadata = {
+    features: {
+      blobs: true,
+      functions: true,
+    },
+    name: 'Netlify',
+  }
+
+  await blobs.set('simple-key', 'value 1')
+  expect(await blobs.get('simple-key')).toBe('value 1')
+
+  await blobs.set('simple-key', 'value 2', { metadata })
+  expect(await blobs.get('simple-key')).toBe('value 2')
+
+  await blobs.set('parent/child', 'value 3')
+  expect(await blobs.get('parent/child')).toBe('value 3')
+  expect(await blobs.get('parent')).toBe(null)
+
+  const entry = await blobs.getWithMetadata('simple-key')
+  expect(entry?.metadata).toEqual(metadata)
+
+  await blobs.delete('simple-key')
+  expect(await blobs.get('simple-key')).toBe(null)
+
+  await server.stop()
+  await fs.rm(directory.path, { force: true, recursive: true })
+})
diff --git a/src/server.ts b/src/server.ts
index ba14875..095c919 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,3 +1,4 @@
+import { createHmac } from 'node:crypto'
 import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
 import http from 'node:http'
 import { tmpdir } from 'node:os'
@@ -7,6 +8,9 @@ import { ListResponse } from './backend/list.ts'
 import { decodeMetadata, encodeMetadata, METADATA_HEADER_INTERNAL } from './metadata.ts'
 import { isNodeError, Logger } from './util.ts'
 
+const API_URL_PATH = /\/api\/v1\/sites\/(?<site_id>[^/]+)\/blobs\/?(?<key>[^?]*)/
+const DEFAULT_STORE = 'production'
+
 interface BlobsServerOptions {
   /**
    * Whether debug-level information should be logged, such as internal errors
@@ -44,6 +48,7 @@ export class BlobsServer {
   private port: number
   private server?: http.Server
   private token?: string
+  private tokenHash: string
 
   constructor({ debug, directory, logger, port, token }: BlobsServerOptions) {
     this.address = ''
@@ -52,6 +57,9 @@ export class BlobsServer {
     this.logger = logger ?? console.log
     this.port = port || 0
     this.token = token
+    this.tokenHash = createHmac('sha256', Math.random.toString())
+      .update(token ?? Math.random.toString())
+      .digest('hex')
   }
 
   logDebug(...message: unknown[]) {
@@ -222,10 +230,23 @@ export class BlobsServer {
   }
 
   handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
-    if (!this.validateAccess(req)) {
+    if (!req.url || !this.validateAccess(req)) {
       return this.sendResponse(req, res, 403)
     }
 
+    const apiURLMatch = req.url.match(API_URL_PATH)
+
+    // If this matches an API URL, return a signed URL.
+    if (apiURLMatch) {
+      const fullURL = new URL(req.url, this.address)
+      const storeName = fullURL.searchParams.get('context') ?? DEFAULT_STORE
+      const key = apiURLMatch.groups?.key as string
+      const siteID = apiURLMatch.groups?.site_id as string
+      const url = `${this.address}/${siteID}/${storeName}/${key}?signature=${this.tokenHash}`
+
+      return this.sendResponse(req, res, 200, JSON.stringify({ url }))
+    }
+
     switch (req.method) {
       case 'DELETE':
         return this.delete(req, res)
@@ -294,11 +315,22 @@ export class BlobsServer {
     const { authorization = '' } = req.headers
     const parts = authorization.split(' ')
 
-    if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
+    if (parts.length === 2 || (parts[0].toLowerCase() === 'bearer' && parts[1] === this.token)) {
+      return true
+    }
+
+    if (!req.url) {
       return false
     }
 
-    return parts[1] === this.token
+    const url = new URL(req.url, this.address)
+    const signature = url.searchParams.get('signature')
+
+    if (signature === this.tokenHash) {
+      return true
+    }
+
+    return false
   }
 
   /**