Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: http and server improvements #170

Merged
merged 2 commits into from
Feb 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions docs/content/4.http-server.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# HTTP Server

We can easily expose unstorage instance to an http server to allow remote connections.

Request url is mapped to key and method/body mapped to function. See below for supported http methods.

You can use [http driver](/drivers/http) to easily connect to compatible server.

**🛡️ Security Note:** Server is unprotected by default. You need to add your own authentication/security middleware like basic authentication.
Also consider that even with authentication, unstorage should not be exposed to untrusted users since it has no protection for abuse (DDOS, Filesystem escalation, etc)

Expand All @@ -20,15 +23,13 @@ const storageServer = createStorageServer(storage);
await listen(storageServer.handle);
```

**Using CLI:**

```sh
npx unstorage .
```

**Supported HTTP Methods:**

- `GET`: Maps to `storage.getItem`. Returns list of keys on path if value not found.
- `GET`: Maps to `storage.getItem` or `storage.getKeys` when path ending with `/` or `/:`
- `HEAD`: Maps to `storage.hasItem`. Returns 404 if not found.
- `PUT`: Maps to `storage.setItem`. Value is read from body and returns `OK` if operation succeeded.
- `DELETE`: Maps to `storage.removeItem`. Returns `OK` if operation succeeded.
- `DELETE`: Maps to `storage.removeItem` or `storage.clear` when path ending with `/` or `/:`. Returns `OK` if operation succeeded.

**Raw values support:**

When passing `accept: application/octet-stream` for get and `content-type: application/octet-stream` for set operations, storage server switches to binary mode via `storage.getItemRaw` and `storage.setItemRaw`
30 changes: 24 additions & 6 deletions src/drivers/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { defineDriver } from "./utils";
import { stringify } from "./utils";
import { $fetch } from "ofetch";
import { joinURL } from "ufo";

Expand All @@ -8,7 +7,9 @@ export interface HTTPOptions {
}

export default defineDriver((opts: HTTPOptions = {}) => {
const r = (key: string) => joinURL(opts.base!, key.replace(/:/g, "/"));
const r = (key: string = "") => joinURL(opts.base!, key.replace(/:/g, "/"));
const rBase = (key: string = "") =>
joinURL(opts.base!, (key || "/").replace(/:/g, "/"), ":");

return {
hasItem(key) {
Expand All @@ -20,6 +21,14 @@ export default defineDriver((opts: HTTPOptions = {}) => {
const value = await $fetch(r(key));
return value;
},
async getItemRaw(key) {
const value = await $fetch(r(key), {
headers: {
accept: "application/octet-stream",
},
});
return value;
},
async getMeta(key) {
const res = await $fetch.raw(r(key), { method: "HEAD" });
let mtime = undefined;
Expand All @@ -35,15 +44,24 @@ export default defineDriver((opts: HTTPOptions = {}) => {
async setItem(key, value) {
await $fetch(r(key), { method: "PUT", body: value });
},
async setItemRaw(key, value) {
await $fetch(r(key), {
method: "PUT",
body: value,
headers: {
"content-type": "application/octet-stream",
},
});
},
async removeItem(key) {
await $fetch(r(key), { method: "DELETE" });
},
async getKeys() {
const value = await $fetch(r(""));
async getKeys(base) {
const value = await $fetch(rBase(base));
return Array.isArray(value) ? value : [];
},
clear() {
// Not supported
async clear(base) {
await $fetch(rBase(base), { method: "DELETE" });
},
};
});
119 changes: 75 additions & 44 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,66 +5,97 @@ import {
readBody,
eventHandler,
toNodeListener,
getMethod,
getRequestHeader,
setResponseHeader,
readRawBody,
EventHandler,
} from "h3";
import { Storage } from "./types";
import { stringify } from "./_utils";
import { normalizeKey } from "./utils";

export interface StorageServerOptions {}

export interface StorageServer {
handle: RequestListener;
}

export function createStorageServer(
export function createH3StorageHandler(
storage: Storage,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options: StorageServerOptions = {}
): StorageServer {
const app = createApp();
app.use(
eventHandler(async (event) => {
// GET => getItem
if (event.req.method === "GET") {
const value = await storage.getItem(event.req.url!);
if (!value) {
const keys = await storage.getKeys(event.req.url);
return keys.map((key) => key.replace(/:/g, "/"));
}
): EventHandler {
return eventHandler(async (event) => {
const method = getMethod(event);
const isBaseKey = event.path.endsWith(":") || event.path.endsWith("/");
const key = normalizeKey(event.path);

// GET => getItem
if (method === "GET") {
if (isBaseKey) {
const keys = await storage.getKeys(key);
return keys.map((key) => key.replace(/:/g, "/"));
}

const isRaw =
getRequestHeader(event, "accept") === "application/octet-stream";
if (isRaw) {
const value = await storage.getItemRaw(key);
return value;
} else {
const value = await storage.getItem(key);
return stringify(value);
}
// HEAD => hasItem + meta (mtime)
if (event.req.method === "HEAD") {
const _hasItem = await storage.hasItem(event.req.url!);
event.res.statusCode = _hasItem ? 200 : 404;
if (_hasItem) {
const meta = await storage.getMeta(event.req.url!);
if (meta.mtime) {
event.res.setHeader(
"Last-Modified",
new Date(meta.mtime).toUTCString()
);
}
}

// HEAD => hasItem + meta (mtime)
if (method === "HEAD") {
const _hasItem = await storage.hasItem(key);
event.node.res.statusCode = _hasItem ? 200 : 404;
if (_hasItem) {
const meta = await storage.getMeta(key);
if (meta.mtime) {
setResponseHeader(
event,
"last-modified",
new Date(meta.mtime).toUTCString()
);
}
return "";
}
// PUT => setItem
if (event.req.method === "PUT") {
return "";
}

// PUT => setItem
if (method === "PUT") {
const isRaw =
getRequestHeader(event, "content-type") === "application/octet-stream";
if (isRaw) {
const value = await readRawBody(event);
await storage.setItemRaw(key, value);
} else {
const value = await readBody(event);
await storage.setItem(event.req.url!, value);
return "OK";
}
// DELETE => removeItem
if (event.req.method === "DELETE") {
await storage.removeItem(event.req.url!);
return "OK";
await storage.setItem(key, value);
}
throw createError({
statusCode: 405,
statusMessage: "Method Not Allowed",
});
})
);
return "OK";
}

// DELETE => removeItem
if (method === "DELETE") {
await (isBaseKey ? storage.clear(key) : storage.removeItem(key));
return "OK";
}

throw createError({
statusCode: 405,
statusMessage: `Method Not Allowed: ${method}`,
});
});
}

export function createStorageServer(
storage: Storage,
options: StorageServerOptions = {}
): { handle: RequestListener } {
const app = createApp();
const handler = createH3StorageHandler(storage, options);
app.use(handler);
return {
handle: toNodeListener(app),
};
Expand Down
37 changes: 13 additions & 24 deletions test/drivers/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,22 @@
import { describe, it, expect } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import driver from "../../src/drivers/http";
import { createStorage } from "../../src";
import { createStorageServer } from "../../src/server";
import { listen } from "listhen";
import { testDriver } from "./utils";

describe("drivers: http", () => {
it("basic", async () => {
const storage = createStorage();
const server = createStorageServer(storage);

const { url, close } = await listen(server.handle, {
port: { random: true },
});
storage.mount("/http", driver({ base: url }));

expect(await storage.hasItem("/http/foo")).toBe(false);

await storage.setItem("/http/foo", "bar");
expect(await storage.getItem("http:foo")).toBe("bar");
expect(await storage.hasItem("/http/foo")).toBe(true);

const date = new Date();
await storage.setMeta("/http/foo", { mtime: date });
describe("drivers: http", async () => {
const remoteStorage = createStorage();
const server = createStorageServer(remoteStorage);
const listener = await listen(server.handle, {
port: { random: true },
});

expect(await storage.getMeta("/http/foo")).toMatchObject({
mtime: date,
status: 200,
});
afterAll(async () => {
await listener.close();
});

await close();
testDriver({
driver: driver({ base: listener!.url }),
});
});
4 changes: 2 additions & 2 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe("server", () => {
const fetchStorage = (url: string, options?: any) =>
$fetch(url, { baseURL: serverURL, ...options });

expect(await fetchStorage("foo", {})).toMatchObject([]);
expect(await fetchStorage("foo/", {})).toMatchObject([]);

await storage.setItem("foo/bar", "bar");
await storage.setMeta("foo/bar", { mtime: new Date() });
Expand All @@ -28,7 +28,7 @@ describe("server", () => {
expect(await fetchStorage("/")).toMatchObject(["foo/bar"]);

expect(await fetchStorage("foo/bar", { method: "DELETE" })).toBe("OK");
expect(await fetchStorage("foo/bar", {})).toMatchObject([]);
expect(await fetchStorage("foo/bar/", {})).toMatchObject([]);

await close();
});
Expand Down