first commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
# @wrnexus/uploader
|
||||
|
||||
Config-driven file uploads + serving for [WrNexus](https://www.npmjs.com/org/wrnexus). Declare
|
||||
named **storage stores** (local disk or any S3-compatible backend) in `wrnexus.config.ts`, upload
|
||||
with one function call, drop a drag-and-drop widget on a page, and serve files back — public or
|
||||
private. Zero external dependencies (S3 is signed with a built-in AWS SigV4 implementation, like the
|
||||
rest of the framework).
|
||||
|
||||
## Configure
|
||||
|
||||
```ts
|
||||
// wrnexus.config.ts
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
storage: {
|
||||
default: "public",
|
||||
stores: {
|
||||
// Local disk, world-readable — served by the framework with a 1-year cache.
|
||||
public: {
|
||||
driver: "local",
|
||||
dir: "uploads/public", // relative to the app root (dev) / cwd (prod)
|
||||
access: "public",
|
||||
maxBytes: 10_000_000,
|
||||
accept: ["image/*", ".pdf"], // MIME, "type/*" wildcards, or ".ext"
|
||||
},
|
||||
// Private S3 (works with AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces).
|
||||
docs: {
|
||||
driver: "s3",
|
||||
access: "private",
|
||||
bucket: "my-bucket",
|
||||
region: "auto",
|
||||
endpoint: "https://<acct>.r2.cloudflarestorage.com",
|
||||
accessKeyId: process.env.S3_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET!,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Upload (server)
|
||||
|
||||
```ts
|
||||
// app/api/upload.ts — one-liner
|
||||
import { handleUpload } from "@wrnexus/uploader";
|
||||
export const POST = handleUpload({ store: "public" });
|
||||
// → { ok: true, files: [{ key, url, name, type, size }] }
|
||||
```
|
||||
|
||||
```ts
|
||||
// or drive it yourself, anywhere you have the request
|
||||
import { upload, getStore } from "@wrnexus/uploader";
|
||||
const { files } = await upload("docs", ctx.req, { prefix: "invoices" });
|
||||
await getStore("docs").driver.delete(files[0].key);
|
||||
```
|
||||
|
||||
Uploads are validated (size + type), stored under a random, collision-proof, path-safe key
|
||||
(the client filename is never used as a path), and — for public stores — returned with a servable
|
||||
`url`.
|
||||
|
||||
## Widget (client)
|
||||
|
||||
Drop the element anywhere; the runtime (drag-and-drop, per-file progress, success/failed states) is
|
||||
auto-injected on pages that contain `data-uploader`:
|
||||
|
||||
```html
|
||||
<div
|
||||
data-uploader="public"
|
||||
data-endpoint="/api/upload"
|
||||
data-accept="image/*"
|
||||
data-max="10000000"
|
||||
data-multiple
|
||||
></div>
|
||||
```
|
||||
|
||||
Or via the first-party UI component:
|
||||
|
||||
```html
|
||||
<div
|
||||
data-component="file-upload"
|
||||
store="public"
|
||||
endpoint="/api/upload"
|
||||
accept="image/*"
|
||||
multiple="true"
|
||||
></div>
|
||||
```
|
||||
|
||||
It dispatches bubbling events you can listen for:
|
||||
|
||||
- `wrnexus:upload` — `detail: { file, result: { key, url, name, size, type } }`
|
||||
- `wrnexus:upload-error` — `detail: { file, error }`
|
||||
|
||||
## Serve files
|
||||
|
||||
- **Public + local** → served automatically at `/__wrnexus/uploads/<store>/<key>` (immutable cache).
|
||||
- **Public + S3** → `url` is the bucket/CDN URL directly.
|
||||
- **Private** (any driver) → mount a route and gate it with your auth middleware:
|
||||
|
||||
```ts
|
||||
// app/api/files/[key].ts
|
||||
import { serveFromStore } from "@wrnexus/uploader";
|
||||
export const GET = serveFromStore("docs"); // your middleware decides who gets in
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Export | What |
|
||||
| --------------------------------------- | --------------------------------------------------------------- |
|
||||
| `handleUpload(opts)` | POST route handler → JSON `{ ok, files }` |
|
||||
| `upload(store, req, opts)` | Parse + validate + store; returns `{ files }` |
|
||||
| `serveFromStore(store)` | Route handler that streams an object back (gate it for private) |
|
||||
| `getStore(name?)` / `hasStorage(name?)` | Reach a store's `driver` (`put`/`get`/`delete`/`publicUrl`) |
|
||||
| `configureStorage(config, root)` | Build the registry (the framework calls this at startup) |
|
||||
| `s3Driver` / `localDriver` / `signS3` | Lower-level building blocks |
|
||||
|
||||
## Notes
|
||||
|
||||
- Uploads count against the server's `maxBodyBytes`; per-file limits use each store's `maxBytes`.
|
||||
- SigV4 signing is implemented from scratch (no `@aws-sdk`); tested against local S3 semantics.
|
||||
Live AWS/R2 connectivity depends on your credentials + bucket policy.
|
||||
- v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB).
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Process-wide store registry, configured once at server startup from the
|
||||
* `storage` block in `wrnexus.config.ts` (mirrors `@wrnexus/db`'s registry).
|
||||
* Handlers then call `getStore("<name>")` — or omit the name for the default.
|
||||
*/
|
||||
|
||||
import type { StorageConfig, StorageDriver, StoreAccess, StoreConfig } from "./driver.ts";
|
||||
import { localDriver } from "./adapters/local.ts";
|
||||
import { s3Driver } from "./adapters/s3.ts";
|
||||
|
||||
export interface Store {
|
||||
name: string;
|
||||
access: StoreAccess;
|
||||
driver: StorageDriver;
|
||||
config: StoreConfig;
|
||||
}
|
||||
|
||||
const stores = new Map<string, Store>();
|
||||
let defaultName: string | undefined;
|
||||
|
||||
/** Build a driver per configured store. Safe to call again (fully replaces). */
|
||||
export function configureStorage(config: StorageConfig | undefined, appRoot: string): void {
|
||||
stores.clear();
|
||||
defaultName = undefined;
|
||||
if (!config?.stores) return;
|
||||
for (const [name, cfg] of Object.entries(config.stores)) {
|
||||
const driver = cfg.driver === "s3" ? s3Driver(cfg) : localDriver(cfg, appRoot);
|
||||
stores.set(name, { name, access: cfg.access, driver, config: cfg });
|
||||
}
|
||||
defaultName = config.default ?? Object.keys(config.stores)[0];
|
||||
}
|
||||
|
||||
/** Whether the default (or a named) store is configured. */
|
||||
export function hasStorage(name?: string): boolean {
|
||||
const n = name ?? defaultName;
|
||||
return !!n && stores.has(n);
|
||||
}
|
||||
|
||||
/** The default store, or a named one. Throws if it isn't configured. */
|
||||
export function getStore(name?: string): Store {
|
||||
const n = name ?? defaultName;
|
||||
const store = n ? stores.get(n) : undefined;
|
||||
if (!store) {
|
||||
throw new Error(
|
||||
n
|
||||
? `No storage store '${n}'. Add it under storage.stores in wrnexus.config.ts.`
|
||||
: "No storage configured. Add a `storage` block to wrnexus.config.ts.",
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Names of all configured stores. */
|
||||
export function storeNames(): string[] {
|
||||
return [...stores.keys()];
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Storage driver contract + config types.
|
||||
*
|
||||
* A `StorageDriver` is the low-level object store (local disk, S3, …). It knows
|
||||
* how to put/get/delete raw bytes under a key — nothing about HTTP, multipart
|
||||
* parsing, validation, or URLs. The registry (`client.ts`) builds one driver per
|
||||
* configured store and the upload layer (`upload.ts`) drives them. This mirrors
|
||||
* `@wrnexus/db`'s driver/adapter split.
|
||||
*/
|
||||
|
||||
/** Whether a store's objects are world-readable or served behind app auth. */
|
||||
export type StoreAccess = "public" | "private";
|
||||
|
||||
/** An object read back from a store. */
|
||||
export interface StoredObject {
|
||||
/** Object bytes as a web stream (preferred) or a buffer. */
|
||||
body: ReadableStream<Uint8Array> | Uint8Array;
|
||||
/** MIME type to serve with. */
|
||||
contentType: string;
|
||||
/** Size in bytes, when known. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** Metadata passed alongside the bytes on `put`. */
|
||||
export interface PutMeta {
|
||||
contentType: string;
|
||||
/** Original client filename (informational only — NEVER used as a path). */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
/** The low-level object store. Implementations: `adapters/local.ts`, `adapters/s3.ts`. */
|
||||
export interface StorageDriver {
|
||||
/** Persist `data` under `key` (overwrites). */
|
||||
put(key: string, data: Uint8Array, meta: PutMeta): Promise<void>;
|
||||
/** Fetch an object, or `null` if it doesn't exist. */
|
||||
get(key: string): Promise<StoredObject | null>;
|
||||
/** Remove an object. No error if it's already gone. */
|
||||
delete(key: string): Promise<void>;
|
||||
/**
|
||||
* A directly-servable absolute URL for a PUBLIC object (e.g. an S3/CDN URL), or
|
||||
* `null` when the framework should serve it (local public stores). Private
|
||||
* stores always return `null`.
|
||||
*/
|
||||
publicUrl(key: string): string | null;
|
||||
}
|
||||
|
||||
/** Local-disk store. `dir` is resolved against the app root when relative. */
|
||||
export interface LocalStoreConfig {
|
||||
driver: "local";
|
||||
access: StoreAccess;
|
||||
/** Directory the files live under (e.g. "uploads/public"). */
|
||||
dir: string;
|
||||
/** Reject files larger than this many bytes (per file). */
|
||||
maxBytes?: number;
|
||||
/** Allowed types: MIME (`"image/*"`, `"application/pdf"`) and/or extensions (`".pdf"`). */
|
||||
accept?: string[];
|
||||
}
|
||||
|
||||
/** S3 / S3-compatible store (AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces). */
|
||||
export interface S3StoreConfig {
|
||||
driver: "s3";
|
||||
access: StoreAccess;
|
||||
bucket: string;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
/**
|
||||
* Custom endpoint for non-AWS services, e.g.
|
||||
* `https://<acct>.r2.cloudflarestorage.com`. Omit for AWS S3.
|
||||
*/
|
||||
endpoint?: string;
|
||||
/** Force path-style URLs (`/bucket/key`). Defaults on for custom endpoints. */
|
||||
forcePathStyle?: boolean;
|
||||
/** Public base URL for `publicUrl()` (a CDN or public bucket domain). */
|
||||
publicBaseUrl?: string;
|
||||
maxBytes?: number;
|
||||
accept?: string[];
|
||||
}
|
||||
|
||||
export type StoreConfig = LocalStoreConfig | S3StoreConfig;
|
||||
|
||||
/** The `storage` block in `wrnexus.config.ts`. */
|
||||
export interface StorageConfig {
|
||||
/** Name of the store used when a call omits one. Defaults to the first store. */
|
||||
default?: string;
|
||||
/** Named stores, reached with `getStore("<name>")` / `upload("<name>", …)`. */
|
||||
stores: Record<string, StoreConfig>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* `@wrnexus/uploader` — config-driven file uploads + serving.
|
||||
*
|
||||
* Declare stores in `wrnexus.config.ts`:
|
||||
*
|
||||
* storage: {
|
||||
* default: "public",
|
||||
* stores: {
|
||||
* public: { driver: "local", dir: "uploads/public", access: "public",
|
||||
* maxBytes: 10_000_000, accept: ["image/*", ".pdf"] },
|
||||
* docs: { driver: "s3", access: "private", bucket: "…", region: "auto",
|
||||
* endpoint: "https://…r2.cloudflarestorage.com",
|
||||
* accessKeyId: process.env.S3_KEY!, secretAccessKey: process.env.S3_SECRET! },
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* Upload from an API route, and drop a drag-and-drop widget on a page:
|
||||
*
|
||||
* // app/api/upload.ts
|
||||
* export const POST = handleUpload({ store: "public" });
|
||||
*
|
||||
* <!-- any page -->
|
||||
* <div data-uploader="public" data-endpoint="/api/upload" data-accept="image/*"></div>
|
||||
*
|
||||
* Public files serve themselves (local → framework route, S3 → bucket URL).
|
||||
* Private files serve through a route you gate with your auth middleware:
|
||||
*
|
||||
* // app/api/files/[key].ts
|
||||
* export const GET = serveFromStore("docs");
|
||||
*/
|
||||
|
||||
export {
|
||||
upload,
|
||||
handleUpload,
|
||||
serveFromStore,
|
||||
serveStoredFile,
|
||||
storedUrl,
|
||||
UploadError,
|
||||
UPLOADS_PREFIX,
|
||||
} from "./upload.ts";
|
||||
export type { UploadedFile, UploadOptions } from "./upload.ts";
|
||||
|
||||
export { configureStorage, getStore, hasStorage, storeNames } from "./client.ts";
|
||||
export type { Store } from "./client.ts";
|
||||
|
||||
export { UPLOAD_RUNTIME, UPLOAD_JS_HREF } from "./runtime.ts";
|
||||
|
||||
export { localDriver } from "./adapters/local.ts";
|
||||
export { s3Driver } from "./adapters/s3.ts";
|
||||
export { signS3, sha256Hex, encodeKey } from "./sigv4.ts";
|
||||
export { accepts, contentTypeOf, extForType, extOf } from "./mime.ts";
|
||||
|
||||
export type {
|
||||
StorageConfig,
|
||||
StoreConfig,
|
||||
LocalStoreConfig,
|
||||
S3StoreConfig,
|
||||
StorageDriver,
|
||||
StoredObject,
|
||||
StoreAccess,
|
||||
PutMeta,
|
||||
} from "./driver.ts";
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Minimal extension ↔ MIME mapping + `accept` matching. Zero-dep: just a table
|
||||
* big enough for the common upload types (images, docs, media, archives).
|
||||
*/
|
||||
|
||||
const BY_EXT: Record<string, string> = {
|
||||
// images
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
svg: "image/svg+xml",
|
||||
ico: "image/x-icon",
|
||||
bmp: "image/bmp",
|
||||
// documents
|
||||
pdf: "application/pdf",
|
||||
txt: "text/plain",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
csv: "text/csv",
|
||||
json: "application/json",
|
||||
xml: "application/xml",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
// media
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mov: "video/quicktime",
|
||||
// archives / misc
|
||||
zip: "application/zip",
|
||||
gz: "application/gzip",
|
||||
tar: "application/x-tar",
|
||||
};
|
||||
|
||||
/** Lowercased extension WITHOUT the dot (e.g. "png"), or "" if none. */
|
||||
export function extOf(name: string): string {
|
||||
const clean = name.split(/[?#]/)[0] ?? "";
|
||||
const dot = clean.lastIndexOf(".");
|
||||
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** MIME type for a filename/key by its extension, or a safe default. */
|
||||
export function contentTypeOf(name: string, fallback = "application/octet-stream"): string {
|
||||
return BY_EXT[extOf(name)] ?? fallback;
|
||||
}
|
||||
|
||||
/** The conventional extension for a MIME type, or "" (used to name S3 keys). */
|
||||
export function extForType(type: string): string {
|
||||
const t = type.split(";")[0]!.trim().toLowerCase();
|
||||
for (const [ext, mime] of Object.entries(BY_EXT)) if (mime === t) return ext;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `file` (its MIME `type` + `name`) satisfy an `accept` list? Each accept
|
||||
* entry is a MIME type (`"image/png"`), a wildcard MIME (`"image/*"`), or a
|
||||
* dotted extension (`".pdf"`). An empty/omitted list accepts everything.
|
||||
*/
|
||||
export function accepts(
|
||||
accept: string[] | undefined,
|
||||
file: { type: string; name: string },
|
||||
): boolean {
|
||||
if (!accept || accept.length === 0) return true;
|
||||
const type = (file.type || contentTypeOf(file.name)).toLowerCase();
|
||||
const ext = "." + extOf(file.name);
|
||||
return accept.some((raw) => {
|
||||
const rule = raw.trim().toLowerCase();
|
||||
if (rule.startsWith(".")) return rule === ext;
|
||||
if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1)); // "image/" prefix
|
||||
return rule === type;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Client runtime for `<div data-uploader>` elements — drag-and-drop + file
|
||||
* input, per-file progress bars, and success/failed states. Injected by
|
||||
* `collectScripts` only on pages that contain `data-uploader` (same mechanism as
|
||||
* `validate.js`). Self-contained: it injects its own themed stylesheet (using
|
||||
* `--wire-*` tokens) and posts each file via XHR so upload progress is live.
|
||||
*
|
||||
* Markup it enhances (also a valid no-JS `<form>` fallback if you wrap it):
|
||||
* <div data-uploader="public" data-endpoint="/api/upload"
|
||||
* data-accept="image/*" data-max="10000000" data-multiple></div>
|
||||
*
|
||||
* Events dispatched on the element (bubble):
|
||||
* wrnexus:upload detail: { file, result: { key, url, name, size, type } }
|
||||
* wrnexus:upload-error detail: { file, error }
|
||||
*
|
||||
* NOTE: written with single/double quotes + string concatenation only — no
|
||||
* backticks and no ${...}, so it embeds safely in the exported template string.
|
||||
*/
|
||||
export const UPLOAD_JS_HREF = "/__wrnexus/uploader.js";
|
||||
|
||||
export const UPLOAD_RUNTIME = `
|
||||
(function () {
|
||||
if (typeof document === "undefined") return;
|
||||
var CSRF_COOKIE = "wire-csrf";
|
||||
|
||||
var CSS =
|
||||
".wire-uploader{display:block}" +
|
||||
".wire-uploader-zone{display:flex;align-items:center;justify-content:center;text-align:center;" +
|
||||
"min-height:8rem;padding:1.25rem;border:2px dashed var(--wire-border,#cbd5e1);border-radius:12px;" +
|
||||
"background:var(--wire-surface,transparent);color:var(--wire-muted,#64748b);cursor:pointer;" +
|
||||
"transition:border-color .15s ease,background-color .15s ease;position:relative}" +
|
||||
".wire-uploader-zone:hover,.wire-uploader-zone:focus-visible{border-color:var(--wire-brand,#3f7dff);outline:none}" +
|
||||
".wire-uploader-zone.is-drag{border-color:var(--wire-brand,#3f7dff);background:color-mix(in oklab,var(--wire-brand,#3f7dff) 8%,transparent)}" +
|
||||
".wire-uploader-prompt{font-size:.9rem;pointer-events:none}" +
|
||||
".wire-uploader-input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}" +
|
||||
".wire-uploader-list{list-style:none;margin:.75rem 0 0;padding:0;display:flex;flex-direction:column;gap:.5rem}" +
|
||||
".wire-uploader-item{display:grid;grid-template-columns:1fr auto;gap:.15rem .75rem;align-items:center;" +
|
||||
"font-size:.82rem;padding:.5rem .7rem;border:1px solid var(--wire-border,#e2e8f0);border-radius:8px}" +
|
||||
".wire-uploader-name{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--wire-text,#0f172a)}" +
|
||||
".wire-uploader-meta{color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums}" +
|
||||
".wire-uploader-bar{grid-column:1/-1;height:5px;border-radius:999px;background:var(--wire-border,#e2e8f0);overflow:hidden}" +
|
||||
".wire-uploader-fill{height:100%;width:0;border-radius:999px;background:var(--wire-brand,#3f7dff);transition:width .15s ease}" +
|
||||
".wire-uploader-status{grid-column:1/-1;font-size:.75rem;color:var(--wire-muted,#94a3b8);font-variant-numeric:tabular-nums}" +
|
||||
".wire-uploader-item.is-done .wire-uploader-fill{background:var(--wire-success,#16a34a)}" +
|
||||
".wire-uploader-item.is-done .wire-uploader-status{color:var(--wire-success,#16a34a)}" +
|
||||
".wire-uploader-item.is-error .wire-uploader-fill{background:var(--wire-danger,#dc2626)}" +
|
||||
".wire-uploader-item.is-error .wire-uploader-status{color:var(--wire-danger,#dc2626)}";
|
||||
|
||||
function injectCss() {
|
||||
if (document.getElementById("wire-uploader-css")) return;
|
||||
var s = document.createElement("style");
|
||||
s.id = "wire-uploader-css";
|
||||
s.textContent = CSS;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function cookie(name) {
|
||||
var m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
function fmt(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / 1048576).toFixed(1) + " MB";
|
||||
}
|
||||
function accepts(accept, file) {
|
||||
var list = (accept || "").split(",").map(function (s) { return s.trim().toLowerCase(); }).filter(Boolean);
|
||||
if (!list.length) return true;
|
||||
var type = (file.type || "").toLowerCase();
|
||||
var name = (file.name || "").toLowerCase();
|
||||
var ext = name.indexOf(".") >= 0 ? name.slice(name.lastIndexOf(".")) : "";
|
||||
return list.some(function (rule) {
|
||||
if (rule.charAt(0) === ".") return rule === ext;
|
||||
if (rule.slice(-2) === "/*") return type.indexOf(rule.slice(0, -1)) === 0;
|
||||
return rule === type;
|
||||
});
|
||||
}
|
||||
|
||||
function setup(root) {
|
||||
if (root.__wrnexusUploader) return;
|
||||
root.__wrnexusUploader = true;
|
||||
|
||||
var endpoint = root.getAttribute("data-endpoint") || "/api/upload";
|
||||
var multipleAttr = root.getAttribute("data-multiple");
|
||||
var multiple = root.hasAttribute("data-multiple") && multipleAttr !== "false";
|
||||
var accept = root.getAttribute("data-accept") || "";
|
||||
var maxBytes = parseInt(root.getAttribute("data-max") || "0", 10) || 0;
|
||||
var field = root.getAttribute("data-field") || (multiple ? "files" : "file");
|
||||
var promptText = root.getAttribute("data-label") || "Drag files here or click to browse";
|
||||
|
||||
root.classList.add("wire-uploader");
|
||||
var zone = el("div", "wire-uploader-zone");
|
||||
zone.setAttribute("role", "button");
|
||||
zone.setAttribute("tabindex", "0");
|
||||
zone.appendChild(el("div", "wire-uploader-prompt", promptText));
|
||||
var input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.className = "wire-uploader-input";
|
||||
if (multiple) input.multiple = true;
|
||||
if (accept) input.accept = accept;
|
||||
zone.appendChild(input);
|
||||
var listEl = el("ul", "wire-uploader-list");
|
||||
root.appendChild(zone);
|
||||
root.appendChild(listEl);
|
||||
|
||||
zone.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); }
|
||||
});
|
||||
["dragenter", "dragover"].forEach(function (ev) {
|
||||
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add("is-drag"); });
|
||||
});
|
||||
["dragleave", "drop"].forEach(function (ev) {
|
||||
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove("is-drag"); });
|
||||
});
|
||||
zone.addEventListener("drop", function (e) {
|
||||
if (e.dataTransfer && e.dataTransfer.files) handle(e.dataTransfer.files);
|
||||
});
|
||||
input.addEventListener("change", function () {
|
||||
if (input.files) handle(input.files);
|
||||
input.value = "";
|
||||
});
|
||||
|
||||
function handle(files) {
|
||||
var arr = Array.prototype.slice.call(files);
|
||||
if (!multiple) arr = arr.slice(0, 1);
|
||||
arr.forEach(uploadOne);
|
||||
}
|
||||
|
||||
function row(file) {
|
||||
var li = el("li", "wire-uploader-item");
|
||||
li.appendChild(el("span", "wire-uploader-name", file.name));
|
||||
li.appendChild(el("span", "wire-uploader-meta", fmt(file.size)));
|
||||
var bar = el("div", "wire-uploader-bar");
|
||||
var fill = el("div", "wire-uploader-fill");
|
||||
bar.appendChild(fill);
|
||||
li.appendChild(bar);
|
||||
var status = el("span", "wire-uploader-status", "");
|
||||
li.appendChild(status);
|
||||
listEl.appendChild(li);
|
||||
return { li: li, fill: fill, status: status };
|
||||
}
|
||||
|
||||
function uploadOne(file) {
|
||||
var ui = row(file);
|
||||
if (maxBytes && file.size > maxBytes) return fail(ui, "Too large (max " + fmt(maxBytes) + ")", file);
|
||||
if (!accepts(accept, file)) return fail(ui, "Type not allowed", file);
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append(field, file, file.name);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", endpoint, true);
|
||||
var token = cookie(CSRF_COOKIE);
|
||||
if (token) xhr.setRequestHeader("x-csrf-token", token);
|
||||
xhr.upload.addEventListener("progress", function (e) {
|
||||
if (e.lengthComputable) {
|
||||
var pct = Math.round((e.loaded / e.total) * 100);
|
||||
ui.fill.style.width = pct + "%";
|
||||
ui.status.textContent = pct + "%";
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", function () {
|
||||
var data = null;
|
||||
try { data = JSON.parse(xhr.responseText); } catch (e2) {}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {
|
||||
done(ui, (data.files && data.files[0]) || null, file);
|
||||
} else {
|
||||
fail(ui, (data && data.error) || ("Upload failed (" + xhr.status + ")"), file);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("error", function () { fail(ui, "Network error", file); });
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
function done(ui, info, file) {
|
||||
ui.li.classList.remove("is-error");
|
||||
ui.li.classList.add("is-done");
|
||||
ui.fill.style.width = "100%";
|
||||
ui.status.textContent = "\\u2713 Uploaded";
|
||||
root.dispatchEvent(new CustomEvent("wrnexus:upload", { bubbles: true, detail: { file: file, result: info } }));
|
||||
}
|
||||
function fail(ui, msg, file) {
|
||||
ui.li.classList.add("is-error");
|
||||
ui.status.textContent = "\\u2717 " + msg;
|
||||
root.dispatchEvent(new CustomEvent("wrnexus:upload-error", { bubbles: true, detail: { file: file, error: msg } }));
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
injectCss();
|
||||
var nodes = document.querySelectorAll("[data-uploader]");
|
||||
for (var i = 0; i < nodes.length; i++) setup(nodes[i]);
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||||
else init();
|
||||
})();
|
||||
`;
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* AWS Signature Version 4 for S3 requests — zero external deps, built on
|
||||
* `node:crypto` + `fetch`. Matches the framework's zero-dep ethos (like
|
||||
* `@wrnexus/ai`) and works with any S3-compatible service (AWS, Cloudflare R2,
|
||||
* Backblaze B2, MinIO, DigitalOcean Spaces).
|
||||
*
|
||||
* Reference: docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
/** Hex-encoded SHA-256 of a payload. */
|
||||
export function sha256Hex(data: Uint8Array | string): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
function hmac(key: Uint8Array | string, data: string): Buffer {
|
||||
return createHmac("sha256", key as Buffer)
|
||||
.update(data, "utf8")
|
||||
.digest();
|
||||
}
|
||||
|
||||
/** `2024-01-02T03:04:05.678Z` → `20240102T030405Z`. */
|
||||
function amzDate(d: Date): string {
|
||||
return d
|
||||
.toISOString()
|
||||
.replace(/[.]\d{3}/, "")
|
||||
.replace(/[:-]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode an S3 object key for the request path. Every character except
|
||||
* the RFC 3986 unreserved set is encoded; `/` between segments is preserved.
|
||||
*/
|
||||
export function encodeKey(key: string): string {
|
||||
return key
|
||||
.split("/")
|
||||
.map((seg) =>
|
||||
encodeURIComponent(seg).replace(
|
||||
/[!*'()]/g,
|
||||
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
|
||||
),
|
||||
)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export interface SignInput {
|
||||
method: string;
|
||||
host: string;
|
||||
/** Canonical URI — already `%`-encoded, begins with `/`. */
|
||||
path: string;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
/** Hex SHA-256 of the body, or `"UNSIGNED-PAYLOAD"`. */
|
||||
payloadHash: string;
|
||||
/** Extra headers to sign (e.g. `content-type`). `host`/`x-amz-*` are added here. */
|
||||
headers?: Record<string, string>;
|
||||
date: Date;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the signed header set for an S3 request. Returns the headers to send
|
||||
* (lowercased names, including `authorization`, `host`, `x-amz-date`,
|
||||
* `x-amz-content-sha256`).
|
||||
*/
|
||||
export function signS3(input: SignInput): Record<string, string> {
|
||||
const service = input.service ?? "s3";
|
||||
const now = amzDate(input.date);
|
||||
const stamp = now.slice(0, 8);
|
||||
|
||||
// Normalize headers to lowercase names + trimmed values; add the required ones.
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(input.headers ?? {})) {
|
||||
headers[k.toLowerCase()] = String(v).trim();
|
||||
}
|
||||
headers["host"] = input.host;
|
||||
headers["x-amz-content-sha256"] = input.payloadHash;
|
||||
headers["x-amz-date"] = now;
|
||||
|
||||
const names = Object.keys(headers).sort();
|
||||
const canonicalHeaders = names.map((n) => `${n}:${headers[n]}\n`).join("");
|
||||
const signedHeaders = names.join(";");
|
||||
|
||||
const canonicalRequest = [
|
||||
input.method.toUpperCase(),
|
||||
input.path,
|
||||
"", // canonical query string (none for our object operations)
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
input.payloadHash,
|
||||
].join("\n");
|
||||
|
||||
const scope = `${stamp}/${input.region}/${service}/aws4_request`;
|
||||
const stringToSign = ["AWS4-HMAC-SHA256", now, scope, sha256Hex(canonicalRequest)].join("\n");
|
||||
|
||||
const kDate = hmac("AWS4" + input.secretAccessKey, stamp);
|
||||
const kRegion = hmac(kDate, input.region);
|
||||
const kService = hmac(kRegion, service);
|
||||
const kSigning = hmac(kService, "aws4_request");
|
||||
const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
|
||||
|
||||
headers["authorization"] =
|
||||
`AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* The HTTP-facing upload + serve layer: parse multipart requests, validate,
|
||||
* store, and serve files back. Built on the store registry (`client.ts`).
|
||||
*/
|
||||
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { getStore, hasStorage, type Store } from "./client.ts";
|
||||
import type { StoredObject } from "./driver.ts";
|
||||
import { accepts, contentTypeOf, extForType, extOf } from "./mime.ts";
|
||||
|
||||
/** Reserved prefix the framework serves PUBLIC local objects from. */
|
||||
export const UPLOADS_PREFIX = "/__wrnexus/uploads/";
|
||||
|
||||
export interface UploadedFile {
|
||||
/** Storage key — pass to `getStore().driver.get/delete` or a serve route. */
|
||||
key: string;
|
||||
/** A servable URL for public objects, or `null` for private stores. */
|
||||
url: string | null;
|
||||
/** Original (sanitized) client filename, for display. */
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
/** Only read files from this form field (default: every file field). */
|
||||
field?: string;
|
||||
/** Override the store's `maxBytes`. */
|
||||
maxBytes?: number;
|
||||
/** Override the store's `accept` list. */
|
||||
accept?: string[];
|
||||
/** Key prefix, e.g. `"avatars"` → keys become `avatars/<yyyy>/<mm>/<rand>.<ext>`. */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/** A 4xx-carrying error so `handleUpload` can map it to a status. */
|
||||
export class UploadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status = 400,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UploadError";
|
||||
}
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
const b = new Uint8Array(16);
|
||||
crypto.getRandomValues(b);
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const pad = (n: number): string => (n < 10 ? "0" + n : String(n));
|
||||
|
||||
/** Build a collision-proof, path-safe key — NEVER derived from client input. */
|
||||
function makeKey(file: { name: string; type: string }, prefix?: string): string {
|
||||
const now = new Date();
|
||||
const ext = extOf(file.name) || extForType(file.type);
|
||||
const dated = `${now.getUTCFullYear()}/${pad(now.getUTCMonth() + 1)}/${randomId()}`;
|
||||
const withExt = ext ? `${dated}.${ext}` : dated;
|
||||
const p = (prefix ?? "").replace(/^\/+|\/+$/g, "");
|
||||
if (p && !isSafeKey(p)) throw new UploadError("unsafe upload prefix", 400);
|
||||
return p ? `${p}/${withExt}` : withExt;
|
||||
}
|
||||
|
||||
function isSafeKey(key: string): boolean {
|
||||
return (
|
||||
!!key &&
|
||||
!key.includes("\\") &&
|
||||
!key.includes("\0") &&
|
||||
!key.startsWith("/") &&
|
||||
key.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..")
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip any path parts from a client filename; keep a friendly display name. */
|
||||
function displayName(name: string): string {
|
||||
return (name.split(/[\\/]/).pop() || "file").slice(0, 255);
|
||||
}
|
||||
|
||||
/** The servable URL for a stored object (public → URL, private → null). */
|
||||
export function storedUrl(store: Store, key: string): string | null {
|
||||
if (store.access !== "public") return null;
|
||||
return store.driver.publicUrl(key) ?? `${UPLOADS_PREFIX}${store.name}/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read multipart file(s) from a request and store them. Throws `UploadError`
|
||||
* (4xx) on validation failures. Call it directly, or use `handleUpload`.
|
||||
*/
|
||||
export async function upload(
|
||||
storeName: string | undefined,
|
||||
req: Request,
|
||||
opts: UploadOptions = {},
|
||||
): Promise<{ files: UploadedFile[] }> {
|
||||
const store = getStore(storeName);
|
||||
const maxBytes = opts.maxBytes ?? store.config.maxBytes;
|
||||
const accept = opts.accept ?? store.config.accept;
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await req.formData();
|
||||
} catch {
|
||||
throw new UploadError("expected a multipart/form-data upload", 400);
|
||||
}
|
||||
|
||||
const blobs: File[] = [];
|
||||
for (const [name, value] of form.entries()) {
|
||||
if (typeof value === "string") continue;
|
||||
if (opts.field && name !== opts.field) continue;
|
||||
blobs.push(value as File);
|
||||
}
|
||||
if (blobs.length === 0) throw new UploadError("no files in upload", 400);
|
||||
|
||||
const files: UploadedFile[] = [];
|
||||
for (const file of blobs) {
|
||||
const type = file.type || contentTypeOf(file.name);
|
||||
if (maxBytes && file.size > maxBytes) {
|
||||
throw new UploadError(`file too large (max ${maxBytes} bytes)`, 413);
|
||||
}
|
||||
if (!accepts(accept, { type, name: file.name })) {
|
||||
throw new UploadError(`file type not allowed: ${type || extOf(file.name) || "unknown"}`, 415);
|
||||
}
|
||||
const key = makeKey({ name: file.name, type }, opts.prefix);
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
await store.driver.put(key, data, { contentType: type, filename: displayName(file.name) });
|
||||
files.push({
|
||||
key,
|
||||
url: storedUrl(store, key),
|
||||
name: displayName(file.name),
|
||||
type,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
return { files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready-made POST handler:
|
||||
*
|
||||
* // app/api/upload.ts
|
||||
* export const POST = handleUpload({ store: "public" });
|
||||
*
|
||||
* Returns `{ ok:true, files:[…] }` on success, or `{ ok:false, error }` with a
|
||||
* 4xx/5xx status.
|
||||
*/
|
||||
export function handleUpload(opts: UploadOptions & { store?: string } = {}) {
|
||||
return async (ctx: Context): Promise<Response> => {
|
||||
try {
|
||||
const { files } = await upload(opts.store, ctx.req, opts);
|
||||
return Response.json({ ok: true, files });
|
||||
} catch (err) {
|
||||
const status = err instanceof UploadError ? err.status : 500;
|
||||
const error = err instanceof Error ? err.message : "upload failed";
|
||||
return Response.json({ ok: false, error }, { status });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fileResponse(obj: StoredObject, cache: string): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": obj.contentType,
|
||||
"cache-control": cache,
|
||||
"x-content-type-options": "nosniff",
|
||||
};
|
||||
if (
|
||||
/^(?:text\/html|image\/svg\+xml|application\/(?:xhtml\+xml|javascript))\b/i.test(
|
||||
obj.contentType,
|
||||
)
|
||||
) {
|
||||
headers["content-disposition"] = 'attachment; filename="download"';
|
||||
}
|
||||
if (typeof obj.size === "number") headers["content-length"] = String(obj.size);
|
||||
return new Response(obj.body as BodyInit, { headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve an object from a store as a route handler — mount it behind your auth
|
||||
* middleware to gate PRIVATE files:
|
||||
*
|
||||
* // app/api/files/[key].ts
|
||||
* export const GET = serveFromStore("docs");
|
||||
*
|
||||
* Reads the key from `ctx.params.key` (or `ctx.params.path`); it may contain `/`.
|
||||
*/
|
||||
export function serveFromStore(storeName?: string, opts: { param?: string } = {}) {
|
||||
return async (ctx: Context): Promise<Response> => {
|
||||
const store = getStore(storeName);
|
||||
const key = ctx.params[opts.param ?? "key"] ?? ctx.params.path ?? "";
|
||||
if (!isSafeKey(key)) return new Response("Not Found", { status: 404 });
|
||||
const obj = await store.driver.get(key);
|
||||
if (!obj) return new Response("Not Found", { status: 404 });
|
||||
const cache =
|
||||
store.access === "public" ? "public, max-age=31536000, immutable" : "private, no-store";
|
||||
return fileResponse(obj, cache);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework asset hook: serve PUBLIC local objects at
|
||||
* `/__wrnexus/uploads/<store>/<key>`. Returns `null` for anything it doesn't
|
||||
* own (unknown/private/S3-backed store) so the caller falls through. Wired into
|
||||
* the dev + prod asset servers.
|
||||
*/
|
||||
export async function serveStoredFile(pathname: string): Promise<Response | null> {
|
||||
if (!pathname.startsWith(UPLOADS_PREFIX)) return null;
|
||||
const rest = pathname.slice(UPLOADS_PREFIX.length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
|
||||
let storeName: string;
|
||||
let key: string;
|
||||
try {
|
||||
storeName = decodeURIComponent(rest.slice(0, slash));
|
||||
key = decodeURIComponent(rest.slice(slash + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isSafeKey(key)) return null;
|
||||
if (!hasStorage(storeName)) return null;
|
||||
|
||||
const store = getStore(storeName);
|
||||
if (store.access !== "public") return null; // private → served via the app route
|
||||
const obj = await store.driver.get(key);
|
||||
if (!obj) return new Response("Not Found", { status: 404 });
|
||||
return fileResponse(obj, "public, max-age=31536000, immutable");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { s3Driver } from "../src/index.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const config = {
|
||||
driver: "s3" as const,
|
||||
access: "public" as const,
|
||||
bucket: "bucket",
|
||||
region: "auto",
|
||||
accessKeyId: "key",
|
||||
secretAccessKey: "secret",
|
||||
endpoint: "https://storage.example.com/base/",
|
||||
};
|
||||
|
||||
test("S3 driver preserves endpoint path prefixes and signs encoded keys", async () => {
|
||||
let url = "";
|
||||
let init: RequestInit | undefined;
|
||||
globalThis.fetch = (async (input: string | URL | Request, options?: RequestInit) => {
|
||||
url = String(input);
|
||||
init = options;
|
||||
return new Response(null, { status: 204 });
|
||||
}) as unknown as typeof fetch;
|
||||
await s3Driver(config).put("folder/a b.txt", new TextEncoder().encode("x"), {
|
||||
contentType: "text/plain",
|
||||
});
|
||||
expect(url).toBe("https://storage.example.com/base/bucket/folder/a%20b.txt");
|
||||
expect(new Headers(init?.headers).get("authorization")).toStartWith("AWS4-HMAC-SHA256");
|
||||
});
|
||||
|
||||
test("S3 driver rejects unsafe configuration early", () => {
|
||||
expect(() => s3Driver({ ...config, bucket: "../bucket" })).toThrow("bucket");
|
||||
expect(() => s3Driver({ ...config, endpoint: "ftp://storage.example.com" })).toThrow(
|
||||
"http or https",
|
||||
);
|
||||
expect(() =>
|
||||
s3Driver({ ...config, endpoint: "https://storage.example.com/base", forcePathStyle: false }),
|
||||
).toThrow("path prefix");
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { configureStorage, getStore, serveStoredFile, upload } from "../src/index.ts";
|
||||
|
||||
function configure() {
|
||||
configureStorage(
|
||||
{ stores: { public: { driver: "local", access: "public", dir: "files" } } },
|
||||
mkdtempSync(join(tmpdir(), "wrnexus-upload-")),
|
||||
);
|
||||
}
|
||||
|
||||
test("rejects unsafe upload prefixes before writing", async () => {
|
||||
configure();
|
||||
const form = new FormData();
|
||||
form.set("file", new File(["hello"], "hello.txt", { type: "text/plain" }));
|
||||
const request = new Request("http://local/upload", { method: "POST", body: form });
|
||||
await expect(upload("public", request, { prefix: "../escape" })).rejects.toThrow(
|
||||
"unsafe upload prefix",
|
||||
);
|
||||
});
|
||||
|
||||
test("public active content is attachment-only and cannot be MIME-sniffed", async () => {
|
||||
configure();
|
||||
await getStore("public").driver.put(
|
||||
"safe/page.html",
|
||||
new TextEncoder().encode("<script>x</script>"),
|
||||
{
|
||||
contentType: "text/html",
|
||||
},
|
||||
);
|
||||
const response = await serveStoredFile("/__wrnexus/uploads/public/safe/page.html");
|
||||
expect(response?.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response?.headers.get("content-disposition")).toStartWith("attachment");
|
||||
});
|
||||
Reference in New Issue
Block a user