first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+123
View File
@@ -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).
+13
View File
@@ -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:*"
}
}
+55
View File
@@ -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;
},
};
}
+102
View File
@@ -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 "";
}
}
+56
View File
@@ -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()];
}
+88
View File
@@ -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>;
}
+62
View File
@@ -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";
+81
View File
@@ -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;
});
}
+202
View File
@@ -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();
})();
`;
+109
View File
@@ -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;
}
+227
View File
@@ -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");
}
+42
View File
@@ -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");
});
+36
View File
@@ -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");
});