diff --git a/src/server.test.ts b/src/server.test.ts index 0e7264a..68692c7 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -5,7 +5,7 @@ import semver from 'semver' import tmp from 'tmp-promise' import { test, expect, beforeAll, afterEach } from 'vitest' -import { getDeployStore, getStore } from './main.js' +import { getDeployStore, getStore, listStores } from './main.js' import { BlobsServer } from './server.js' beforeAll(async () => { @@ -311,3 +311,58 @@ test('Works with a deploy-scoped store', async () => { await server.stop() await fs.rm(directory.path, { force: true, recursive: true }) }) + +test('Lists site stores', async () => { + const directory = await tmp.dir() + const server = new BlobsServer({ + directory: directory.path, + token, + }) + const { port } = await server.start() + + const store1 = getStore({ + edgeURL: `http://localhost:${port}`, + name: 'coldplay', + token, + siteID, + }) + + await store1.set('parachutes/shiver', "I'll always be waiting for you") + await store1.set('parachutes/spies', 'And the spies came out of the water') + await store1.set('parachutes/trouble', 'And I:I never meant to cause you trouble') + await store1.set('a-rush-of-blood-to-the-head/politik', 'Give me heart and give me soul') + await store1.set('a-rush-of-blood-to-the-head/in-my-place', 'How long must you wait for it?') + await store1.set('a-rush-of-blood-to-the-head/the-scientist', 'Questions of science, science and progress') + + const store2 = getStore({ + edgeURL: `http://localhost:${port}`, + name: 'phoenix', + token, + siteID, + }) + + await store2.set('united/too-young', "Oh rainfalls and hard times coming they won't leave me tonight") + await store2.set('united/party-time', 'Summertime is gone') + await store2.set('ti-amo/j-boy', 'Something in the middle of the side of the store') + await store2.set('ti-amo/fleur-de-lys', 'No rest till I get to you, no rest till I get to you') + + const store3 = getDeployStore({ + deployID: '655f77a1b48f470008e5879a', + edgeURL: `http://localhost:${port}`, + token, + siteID, + }) + + await store3.set('not-a-song', "I'm a deploy, not a song") + + const { stores } = await listStores({ + edgeURL: `http://localhost:${port}`, + token, + siteID, + }) + + await server.stop() + await fs.rm(directory.path, { force: true, recursive: true }) + + expect(stores).toStrictEqual(['coldplay', 'phoenix']) +}) diff --git a/src/server.ts b/src/server.ts index cd13e20..4570ffe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -141,13 +141,19 @@ export class BlobsServer { const { dataPath, key, metadataPath, rootPath } = this.getLocalPaths(url) - if (!dataPath || !metadataPath) { + // If there's no root path, the request is invalid. + if (!rootPath) { return this.sendResponse(req, res, 400) } - // If there is no key in the URL, it means a `list` operation. + // If there's no data or metadata paths, it means we're listing stores. + if (!dataPath || !metadataPath) { + return this.listStores(req, res, rootPath, url.searchParams.get('prefix') ?? '') + } + + // If there is no key in the URL, it means we're listing blobs. if (!key) { - return this.list({ dataPath, metadataPath, rootPath, req, res, url }) + return this.listBlobs({ dataPath, metadataPath, rootPath, req, res, url }) } this.onRequest({ type: Operation.GET }) @@ -213,7 +219,7 @@ export class BlobsServer { res.end() } - async list(options: { + async listBlobs(options: { dataPath: string metadataPath: string rootPath: string @@ -248,6 +254,22 @@ export class BlobsServer { return this.sendResponse(req, res, 200, JSON.stringify(result)) } + async listStores(req: http.IncomingMessage, res: http.ServerResponse, rootPath: string, prefix: string) { + try { + const allStores = await fs.readdir(rootPath) + const filteredStores = allStores + // Store names are URI-encoded on Windows, so we must decode them first. + .map((store) => (platform === 'win32' ? decodeURIComponent(store) : store)) + .filter((store) => store.startsWith(prefix)) + + return this.sendResponse(req, res, 200, JSON.stringify({ stores: filteredStores })) + } catch (error) { + this.logDebug('Could not list stores:', error) + + return this.sendResponse(req, res, 500) + } + } + async put(req: http.IncomingMessage, res: http.ServerResponse) { const apiMatch = this.parseAPIRequest(req) @@ -304,18 +326,24 @@ export class BlobsServer { const [, siteID, rawStoreName, ...key] = url.pathname.split('/') - if (!siteID || !rawStoreName) { + if (!siteID) { return {} } - // On Windows, file paths can't include the `:` character, which is used in - // deploy-scoped stores. + const rootPath = resolve(this.directory, 'entries', siteID) + + if (!rawStoreName) { + return { rootPath } + } + + // On Windows, file paths can't include the `:` character, so we URI-encode + // them. const storeName = platform === 'win32' ? encodeURIComponent(rawStoreName) : rawStoreName - const rootPath = resolve(this.directory, 'entries', siteID, storeName) - const dataPath = resolve(rootPath, ...key) + const storePath = resolve(rootPath, storeName) + const dataPath = resolve(storePath, ...key) const metadataPath = resolve(this.directory, 'metadata', siteID, storeName, ...key) - return { dataPath, key: key.join('/'), metadataPath, rootPath } + return { dataPath, key: key.join('/'), metadataPath, rootPath: storePath } } handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {