first commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Local-disk storage driver. Files live under a configured directory; keys map
|
||||
* to relative paths inside it. Path traversal is rejected — a key can never
|
||||
* escape the base dir.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import type { LocalStoreConfig, PutMeta, StorageDriver } from "../driver.ts";
|
||||
import { contentTypeOf } from "../mime.ts";
|
||||
|
||||
export function localDriver(config: LocalStoreConfig, appRoot: string): StorageDriver {
|
||||
const base = resolve(isAbsolute(config.dir) ? config.dir : join(appRoot, config.dir));
|
||||
|
||||
/** Resolve a key to an absolute path that MUST stay inside `base`. */
|
||||
function pathFor(key: string): string {
|
||||
const abs = resolve(base, key);
|
||||
if (abs !== base && !abs.startsWith(base + sep)) {
|
||||
throw new Error(`unsafe storage key: ${key}`);
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
return {
|
||||
async put(key, data, _meta: PutMeta) {
|
||||
const file = pathFor(key);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, data);
|
||||
},
|
||||
async get(key) {
|
||||
try {
|
||||
const data = await readFile(pathFor(key));
|
||||
return {
|
||||
body: new Uint8Array(data),
|
||||
contentType: contentTypeOf(key),
|
||||
size: data.byteLength,
|
||||
};
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
async delete(key) {
|
||||
try {
|
||||
await unlink(pathFor(key));
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
}
|
||||
},
|
||||
publicUrl() {
|
||||
// Local objects are served by the framework route (/__wrnexus/uploads/...).
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* S3 (and S3-compatible) storage driver — zero deps, SigV4-signed `fetch`.
|
||||
* Works with AWS S3, Cloudflare R2, Backblaze B2, MinIO, DigitalOcean Spaces.
|
||||
*
|
||||
* Path-style vs virtual-hosted: AWS defaults to virtual-hosted
|
||||
* (`bucket.s3.region.amazonaws.com`); custom endpoints (R2/MinIO) default to
|
||||
* path-style (`endpoint/bucket/key`). Override with `forcePathStyle`.
|
||||
*/
|
||||
|
||||
import type { S3StoreConfig, StorageDriver } from "../driver.ts";
|
||||
import { contentTypeOf } from "../mime.ts";
|
||||
import { encodeKey, sha256Hex, signS3 } from "../sigv4.ts";
|
||||
|
||||
export function s3Driver(config: S3StoreConfig): StorageDriver {
|
||||
if (!config.bucket || /[\\/\0\r\n]/.test(config.bucket)) {
|
||||
throw new TypeError("S3 bucket must be a non-empty name without path separators");
|
||||
}
|
||||
const pathStyle = config.forcePathStyle ?? !!config.endpoint;
|
||||
const endpoint = config.endpoint ?? `https://s3.${config.region}.amazonaws.com`;
|
||||
const parsed = new URL(endpoint);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new TypeError("S3 endpoint must use http or https");
|
||||
}
|
||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||
throw new TypeError("S3 endpoint cannot contain credentials, query parameters, or fragments");
|
||||
}
|
||||
const scheme = parsed.protocol.replace(":", "");
|
||||
const endpointHost = parsed.host;
|
||||
const endpointPath = parsed.pathname.replace(/\/+$/, "");
|
||||
if (!pathStyle && endpointPath) {
|
||||
throw new TypeError("Virtual-hosted S3 endpoints cannot contain a path prefix");
|
||||
}
|
||||
|
||||
function target(key: string): { host: string; path: string; url: string } {
|
||||
const ek = encodeKey(key);
|
||||
if (pathStyle) {
|
||||
const host = endpointHost;
|
||||
const path = `${endpointPath}/${encodeURIComponent(config.bucket)}/${ek}`;
|
||||
return { host, path, url: `${scheme}://${host}${path}` };
|
||||
}
|
||||
const host = `${config.bucket}.${endpointHost}`;
|
||||
const path = `/${ek}`;
|
||||
return { host, path, url: `${scheme}://${host}${path}` };
|
||||
}
|
||||
|
||||
async function send(
|
||||
method: string,
|
||||
key: string,
|
||||
body?: Uint8Array,
|
||||
contentType?: string,
|
||||
): Promise<Response> {
|
||||
const { host, path, url } = target(key);
|
||||
const headers = signS3({
|
||||
method,
|
||||
host,
|
||||
path,
|
||||
region: config.region,
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
payloadHash: sha256Hex(body ?? new Uint8Array()),
|
||||
headers: contentType ? { "content-type": contentType } : {},
|
||||
date: new Date(),
|
||||
});
|
||||
return fetch(url, { method, headers, body: body as BodyInit | undefined });
|
||||
}
|
||||
|
||||
return {
|
||||
async put(key, data, meta) {
|
||||
const res = await send("PUT", key, data, meta.contentType);
|
||||
if (!res.ok) throw new Error(`S3 put failed (${res.status}): ${await peek(res)}`);
|
||||
},
|
||||
async get(key) {
|
||||
const res = await send("GET", key);
|
||||
if (res.status === 404 || res.status === 403) return null;
|
||||
if (!res.ok) throw new Error(`S3 get failed (${res.status})`);
|
||||
const len = res.headers.get("content-length");
|
||||
return {
|
||||
body: res.body ?? new Uint8Array(),
|
||||
contentType: res.headers.get("content-type") || contentTypeOf(key),
|
||||
size: len ? Number(len) : undefined,
|
||||
};
|
||||
},
|
||||
async delete(key) {
|
||||
const res = await send("DELETE", key);
|
||||
if (!res.ok && res.status !== 404) throw new Error(`S3 delete failed (${res.status})`);
|
||||
},
|
||||
publicUrl(key) {
|
||||
if (config.publicBaseUrl) {
|
||||
return `${config.publicBaseUrl.replace(/\/+$/, "")}/${encodeKey(key)}`;
|
||||
}
|
||||
return config.access === "public" ? target(key).url : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function peek(res: Response): Promise<string> {
|
||||
try {
|
||||
return (await res.text()).slice(0, 200);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user