@krizaka/storage 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +23 -2
- package/dist/chunk-3E2PZNBG.js +343 -0
- package/dist/chunk-3E2PZNBG.js.map +1 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/port-CUTAx9CD.d.ts +62 -0
- package/dist/settings.d.ts +68 -0
- package/dist/settings.js +80 -0
- package/dist/settings.js.map +1 -0
- package/dist/storage-7cUuR_Z5.d.ts +73 -0
- package/dist/testkit.d.ts +6 -0
- package/dist/testkit.js +44 -0
- package/dist/testkit.js.map +1 -0
- package/package.json +82 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/port.ts","../src/keys.ts","../src/request-signing.ts","../src/bucket-store.ts","../src/endpoint.ts","../src/folder-store.ts","../src/remote-store.ts"],"sourcesContent":["/**\n * The ports of « storage »: files behind one interface, named by what it does. Generic: no vendor SDK (adapters speak plain HTTP\n * through an injected `fetch`), no environment variable (credentials come in as parameters), no wall clock (a `Clock` signs the links).\n */\nexport type { Clock } from \"@krizaka/db\";\n\n/** The part of `fetch` the adapters use: inject the global one (`fetch`), or a fake in a test. */\nexport type FetchLike = (\n url: string,\n init: { method: string; headers: Record<string, string>; body?: Uint8Array },\n) => Promise<{ ok: boolean; status: number; headers: { get(name: string): string | null }; arrayBuffer(): Promise<ArrayBuffer>; text(): Promise<string> }>;\n\nexport interface StoredObject {\n bytes: Uint8Array;\n contentType: string;\n}\n\nexport interface ObjectInfo {\n key: string;\n size: number;\n}\n\n/** One place files live. Every adapter runs `objectStoreContract` (`@krizaka/storage/testkit`). Keys are `a/b/name.ext`, checked by `assertKey`. */\nexport interface ObjectStore {\n put(key: string, bytes: Uint8Array, meta: { contentType: string }): Promise<void>;\n /** `null` when there is no such file. */\n get(key: string): Promise<StoredObject | null>;\n head(key: string): Promise<ObjectInfo | null>;\n /** Idempotent: deleting a missing file is not an error. */\n delete(key: string): Promise<void>;\n /** Every file whose key starts with `prefix`, in key order, directories flattened. */\n list(prefix: string): Promise<ObjectInfo[]>;\n /** A link valid for `ttlSeconds` for a private file, or `null` when the store cannot sign one. */\n signedUrl(key: string, ttlSeconds: number): Promise<string | null>;\n /** The permanent public link (a pull zone, a CDN), or `null` when the zone is private. */\n publicUrl(key: string): string | null;\n}\n\n/** The refusals of this package: a stable `code`, and the HTTP status a route would answer. */\nexport const STORAGE_CODE = {\n badKey: \"STORAGE_BAD_KEY\",\n badPrefix: \"STORAGE_BAD_PREFIX\",\n tooBig: \"STORAGE_TOO_BIG\",\n badType: \"STORAGE_BAD_TYPE\",\n typeMismatch: \"STORAGE_TYPE_MISMATCH\",\n provider: \"STORAGE_PROVIDER_FAILED\",\n badConfig: \"STORAGE_BAD_CONFIG\",\n badEndpoint: \"STORAGE_BAD_ENDPOINT\",\n} as const;\nexport type StorageCode = (typeof STORAGE_CODE)[keyof typeof STORAGE_CODE];\n\nexport class StorageError extends Error {\n constructor(\n readonly code: StorageCode,\n message: string,\n ) {\n super(message);\n this.name = \"StorageError\";\n }\n}\n","import { createHash } from \"node:crypto\";\n\nimport { STORAGE_CODE, StorageError } from \"./port\";\n\n// @fixed: S3's own limit on a key; a longer one is refused by every provider\nconst MAX_KEY = 1024;\nconst SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._ ()@+=,~-]*$/;\n\n/** Trims slashes at the ends without a regular expression (a long run of slashes must not be quadratic). */\nexport function trimSlashes(value: string, ends: \"both\" | \"end\" = \"both\"): string {\n let start = 0;\n let end = value.length;\n if (ends === \"both\") while (start < end && value[start] === \"/\") start += 1;\n while (end > start && value[end - 1] === \"/\") end -= 1;\n return value.slice(start, end);\n}\n\n/**\n * A key is `a/b/name.ext`: no empty segment, no `.` or `..`, no leading or trailing slash, no backslash, no control character.\n * @throws StorageError `STORAGE_BAD_KEY`\n */\nexport function assertKey(key: string): string {\n const bad = (why: string) => new StorageError(STORAGE_CODE.badKey, `${why}: « ${key.slice(0, 80)} »`);\n if (typeof key !== \"string\" || key === \"\" || key.length > MAX_KEY) throw bad(\"a key is 1 to 1024 characters\");\n for (const segment of key.split(\"/\")) {\n if (segment === \".\" || segment === \"..\") throw bad(\"a key has no . or .. segment\");\n if (!SEGMENT.test(segment)) throw bad(\"a key segment is empty or has a character storage services refuse\");\n }\n return key;\n}\n\n/** The first 10 hex characters of the SHA-256: the rule of the Krizaka media library (`name.<hash>.ext`). */\nexport const hashOf = (bytes: Uint8Array): string => createHash(\"sha256\").update(bytes).digest(\"hex\").slice(0, 10);\n\n/** `photo.jpg` and its bytes give `photo.<sha10>.jpg`: a URL never changes content, so it is cached for good. */\nexport function hashedName(name: string, bytes: Uint8Array): string {\n const dot = name.lastIndexOf(\".\");\n const base = dot > 0 ? name.slice(0, dot) : name;\n const ext = dot > 0 ? name.slice(dot) : \"\";\n return `${base}.${hashOf(bytes)}${ext}`;\n}\n\n/**\n * The prefixes a zone allows, as data: an unknown prefix is refused. A layout lists prefixes WITH a trailing slash (`site/home/`);\n * a key belongs to the prefix it starts with.\n */\nexport interface Layout {\n prefixes: readonly string[];\n /** The prefix a key belongs to, or `null`. */\n prefixOf(key: string): string | null;\n /** @throws StorageError `STORAGE_BAD_PREFIX` when the key is under no declared prefix. */\n check(key: string): string;\n}\n\nexport function defineLayout(prefixes: readonly string[]): Layout {\n for (const p of prefixes) if (!p.endsWith(\"/\")) throw new StorageError(STORAGE_CODE.badPrefix, `layout prefix « ${p} » must end with a slash`);\n const sorted = [...prefixes].sort((a, b) => b.length - a.length);\n const prefixOf = (key: string) => sorted.find((p) => key.startsWith(p)) ?? null;\n return {\n prefixes,\n prefixOf,\n check(key) {\n assertKey(key);\n if (prefixOf(key) === null) throw new StorageError(STORAGE_CODE.badPrefix, `« ${key.slice(0, 80)} » is under no declared prefix`);\n return key;\n },\n };\n}\n\nconst TYPES: Record<string, string> = {\n png: \"image/png\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n gif: \"image/gif\",\n webp: \"image/webp\",\n svg: \"image/svg+xml\",\n pdf: \"application/pdf\",\n mp4: \"video/mp4\",\n webm: \"video/webm\",\n json: \"application/json\",\n txt: \"text/plain\",\n vtt: \"text/vtt\",\n};\n\n/** The content type a file name suggests, for stores that do not keep one. Never a security decision: see `sniffType`. */\nexport const typeOfName = (key: string): string => TYPES[key.split(\".\").pop()?.toLowerCase() ?? \"\"] ?? \"application/octet-stream\";\n","import crypto from \"node:crypto\";\n\n/** AWS Signature Version 4, the part of it any S3-compatible provider needs (header signing and presigned URLs). Dependency-free. */\nconst sha256 = (data: string | Uint8Array) => crypto.createHash(\"sha256\").update(data).digest(\"hex\");\nconst hmac = (key: crypto.BinaryLike | crypto.KeyObject, data: string) => crypto.createHmac(\"sha256\", key).update(data).digest();\nexport const EMPTY_SHA256 = sha256(\"\");\nexport const hashPayload = (bytes: Uint8Array) => sha256(bytes);\n\nexport interface Credentials {\n accessKeyId: string;\n secretAccessKey: string;\n region: string;\n service: string;\n}\n\nconst stamp = (now: Date) => now.toISOString().replace(/[:-]|\\.\\d{3}/g, \"\");\nconst encode = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);\nconst canonicalPath = (url: URL) =>\n url.pathname\n .split(\"/\")\n .map((seg) => encode(decodeURIComponent(seg)))\n .join(\"/\") || \"/\";\nconst byNameThenValue = ([a, av]: readonly [string, string], [b, bv]: readonly [string, string]) => {\n if (a === b) return av < bv ? -1 : 1;\n return a < b ? -1 : 1;\n};\nconst canonicalQuery = (url: URL) =>\n [...url.searchParams.entries()]\n .map(([k, v]) => [encode(k), encode(v)] as const)\n .sort(byNameThenValue)\n .map(([k, v]) => `${k}=${v}`)\n .join(\"&\");\n\nfunction signingKey(c: Credentials, date: string) {\n return hmac(hmac(hmac(hmac(`AWS4${c.secretAccessKey}`, date), c.region), c.service), \"aws4_request\");\n}\n\nfunction signature(c: Credentials, method: string, url: URL, headers: Record<string, string>, payloadHash: string, amzDate: string) {\n const names = Object.keys(headers)\n .map((h) => h.toLowerCase())\n .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); // code-unit order, as the signature requires\n const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v.trim().replace(/\\s+/g, \" \")]));\n const canonical = [method, canonicalPath(url), canonicalQuery(url), names.map((n) => `${n}:${lower[n]}\\n`).join(\"\"), names.join(\";\"), payloadHash].join(\"\\n\");\n const scope = `${amzDate.slice(0, 8)}/${c.region}/${c.service}/aws4_request`;\n const toSign = [\"AWS4-HMAC-SHA256\", amzDate, scope, sha256(canonical)].join(\"\\n\");\n return { signed: names.join(\";\"), scope, value: crypto.createHmac(\"sha256\", signingKey(c, amzDate.slice(0, 8))).update(toSign).digest(\"hex\") };\n}\n\n/** The headers of a signed request: `authorization`, `x-amz-date` and, for S3, `x-amz-content-sha256` (give the headers you send, without host). */\nexport function signRequest(c: Credentials, req: { method: string; url: string; headers?: Record<string, string>; payloadHash: string; now: Date }) {\n const url = new URL(req.url);\n const amzDate = stamp(req.now);\n const extra: Record<string, string> = c.service === \"s3\" ? { \"x-amz-content-sha256\": req.payloadHash } : {};\n const s = signature(c, req.method, url, { ...req.headers, host: url.host, \"x-amz-date\": amzDate, ...extra }, req.payloadHash, amzDate);\n return { ...req.headers, \"x-amz-date\": amzDate, ...extra, authorization: `AWS4-HMAC-SHA256 Credential=${c.accessKeyId}/${s.scope}, SignedHeaders=${s.signed}, Signature=${s.value}` };\n}\n\n/** A presigned GET URL, valid for `ttlSeconds`. */\nexport function presignGet(c: Credentials, url: string, ttlSeconds: number, now: Date): string {\n const u = new URL(url);\n const amzDate = stamp(now);\n u.searchParams.set(\"X-Amz-Algorithm\", \"AWS4-HMAC-SHA256\");\n u.searchParams.set(\"X-Amz-Credential\", `${c.accessKeyId}/${amzDate.slice(0, 8)}/${c.region}/${c.service}/aws4_request`);\n u.searchParams.set(\"X-Amz-Date\", amzDate);\n u.searchParams.set(\"X-Amz-Expires\", String(ttlSeconds));\n u.searchParams.set(\"X-Amz-SignedHeaders\", \"host\");\n const s = signature(c, \"GET\", u, { host: u.host }, \"UNSIGNED-PAYLOAD\", amzDate);\n u.searchParams.set(\"X-Amz-Signature\", s.value);\n return u.toString();\n}\n","import { assertKey, trimSlashes, typeOfName } from \"./keys\";\nimport { type Clock, type FetchLike, type ObjectInfo, type ObjectStore, STORAGE_CODE, StorageError } from \"./port\";\nimport { type Credentials, hashPayload, presignGet, signRequest } from \"./request-signing\";\n\nexport interface BucketStoreOptions {\n /** The provider's origin: `https://tor1.digitaloceanspaces.com`, `https://s3.example.com`, `http://localhost:9000`. */\n endpoint: string;\n region: string;\n bucket: string;\n accessKeyId: string;\n secretAccessKey: string;\n /** Every object lives under this prefix (no slashes at the ends), or none. */\n prefix?: string;\n /** Where the bucket is served publicly (a CDN in front of it), or none: the bucket is private. */\n publicBaseUrl?: string;\n /** Ask the provider to encrypt at rest (`AES256`). */\n serverSideEncryption?: boolean;\n fetch: FetchLike;\n clock: Clock;\n}\n\nconst fail = (what: string, status: number) => new StorageError(STORAGE_CODE.provider, `the bucket refused the ${what} (${status})`);\nconst unescapeXml = (s: string) => s.replaceAll(\"<\", \"<\").replaceAll(\">\", \">\").replaceAll(\""\", '\"').replaceAll(\"'\", \"'\").replaceAll(\"&\", \"&\");\nconst tag = (xml: string, name: string) => new RegExp(`<${name}>([\\\\s\\\\S]*?)</${name}>`).exec(xml)?.[1];\n\n/**\n * Any S3-compatible service (Spaces, a customer's bucket, MinIO) over plain HTTP: path-style URLs and Signature V4, no SDK.\n * `signedUrl` is a presigned GET.\n */\nexport function bucketStore(options: BucketStoreOptions): ObjectStore {\n const creds: Credentials = { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey, region: options.region, service: \"s3\" };\n const prefix = trimSlashes(options.prefix ?? \"\");\n const inBucket = (key: string) => (prefix === \"\" ? \"\" : `${prefix}/`) + key;\n const origin = trimSlashes(options.endpoint, \"end\");\n const encodePath = (path: string) => path.split(\"/\").map(encodeURIComponent).join(\"/\");\n const urlOf = (key: string) => `${origin}/${options.bucket}/${encodePath(inBucket(assertKey(key)))}`;\n const send = (method: string, url: string, body?: Uint8Array, extra: Record<string, string> = {}) => {\n const headers = signRequest(creds, { method, url, headers: extra, payloadHash: hashPayload(body ?? new Uint8Array()), now: options.clock.now() });\n return options.fetch(url, { method, headers, ...(body ? { body } : {}) });\n };\n const list = async (p: string): Promise<ObjectInfo[]> => {\n const out: ObjectInfo[] = [];\n let token: string | undefined;\n do {\n const q = new URL(`${origin}/${options.bucket}`);\n q.searchParams.set(\"list-type\", \"2\");\n q.searchParams.set(\"prefix\", inBucket(p));\n if (token) q.searchParams.set(\"continuation-token\", token);\n const res = await send(\"GET\", q.toString());\n if (!res.ok) throw fail(\"listing\", res.status);\n const xml = await res.text();\n for (const block of xml.split(\"<Contents>\").slice(1)) {\n const key = unescapeXml(tag(block, \"Key\") ?? \"\");\n out.push({ key: prefix === \"\" ? key : key.slice(prefix.length + 1), size: Number(tag(block, \"Size\") ?? 0) });\n }\n token = tag(xml, \"IsTruncated\") === \"true\" ? unescapeXml(tag(xml, \"NextContinuationToken\") ?? \"\") || undefined : undefined;\n } while (token);\n return out.sort((a, b) => a.key.localeCompare(b.key));\n };\n return {\n async put(key, bytes, meta) {\n const res = await send(\"PUT\", urlOf(key), bytes, { \"content-type\": meta.contentType, ...(options.serverSideEncryption ? { \"x-amz-server-side-encryption\": \"AES256\" } : {}) });\n if (!res.ok) throw fail(\"write\", res.status);\n },\n async get(key) {\n const res = await send(\"GET\", urlOf(key));\n if (res.status === 404) return null;\n if (!res.ok) throw fail(\"read\", res.status);\n return { bytes: new Uint8Array(await res.arrayBuffer()), contentType: res.headers.get(\"content-type\") ?? typeOfName(key) };\n },\n async head(key) {\n const res = await send(\"HEAD\", urlOf(key));\n if (res.status === 404) return null;\n if (!res.ok) throw fail(\"head\", res.status);\n return { key, size: Number(res.headers.get(\"content-length\") ?? 0) };\n },\n async delete(key) {\n const res = await send(\"DELETE\", urlOf(key));\n if (!res.ok && res.status !== 404) throw fail(\"delete\", res.status);\n },\n list,\n async signedUrl(key, ttlSeconds) {\n return presignGet(creds, urlOf(key), ttlSeconds, options.clock.now());\n },\n publicUrl: (key) => (options.publicBaseUrl ? `${trimSlashes(options.publicBaseUrl, \"end\")}/${assertKey(key)}` : null),\n };\n}\n","import { STORAGE_CODE, StorageError } from \"./port\";\n\nconst PRIVATE_V4 = [/^10\\./, /^127\\./, /^169\\.254\\./, /^172\\.(1[6-9]|2\\d|3[01])\\./, /^192\\.168\\./, /^0\\./];\n\n/**\n * An endpoint an administrator typed (their own bucket) is called by our server: it must not reach inside our network.\n * Refuses a non-https scheme, credentials in the URL, `localhost`, and literal private, loopback and link-local addresses.\n * A name that RESOLVES to a private address is not caught here: the product's network egress rules are the second guard.\n * `allowLocal` (development, MinIO on the Mac) lets `http` and local addresses through.\n * @returns the origin, without a trailing slash\n * @throws StorageError `STORAGE_BAD_ENDPOINT`\n */\nexport function checkEndpoint(endpoint: string, options: { allowLocal?: boolean } = {}): string {\n const bad = (why: string) => new StorageError(STORAGE_CODE.badEndpoint, `endpoint ${why}`);\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n throw bad(\"is not a URL\");\n }\n if (url.username !== \"\" || url.password !== \"\") throw bad(\"must not carry credentials\");\n if (options.allowLocal) return url.origin;\n if (url.protocol !== \"https:\") throw bad(\"must be https\");\n const host = url.hostname.toLowerCase();\n if (host === \"localhost\" || host.endsWith(\".localhost\") || host.endsWith(\".internal\") || host.endsWith(\".local\")) throw bad(\"must not be a local name\");\n if (PRIVATE_V4.some((re) => re.test(host))) throw bad(\"must not be a private address\");\n if (host.startsWith(\"[\") && (host === \"[::1]\" || host.startsWith(\"[fc\") || host.startsWith(\"[fd\") || host.startsWith(\"[fe80\") || host.startsWith(\"[::ffff:\"))) throw bad(\"must not be a private address\");\n return url.origin;\n}\n","import { mkdir, readdir, readFile, rm, stat, writeFile } from \"node:fs/promises\";\nimport { dirname, join, resolve, sep } from \"node:path\";\n\nimport { assertKey, trimSlashes, typeOfName } from \"./keys\";\nimport type { ObjectInfo, ObjectStore } from \"./port\";\n\nexport interface FolderOptions {\n /** The folder every file lives under. */\n root: string;\n /** Where the folder is served from, when it is (a dev server): the public link. */\n publicBaseUrl?: string;\n}\n\n/** A folder on disk, for development, tests and the Mac apps. A key can never leave the root. */\nexport function folderStore(options: FolderOptions): ObjectStore {\n const root = resolve(options.root);\n const pathOf = (key: string) => {\n const path = resolve(join(root, assertKey(key)));\n if (!path.startsWith(root + sep)) throw new Error(\"a key cannot leave the root\");\n return path;\n };\n const walk = async (dir: string, base: string): Promise<ObjectInfo[]> => {\n let entries;\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch {\n return [];\n }\n const out: ObjectInfo[] = [];\n for (const entry of entries) {\n const key = base === \"\" ? entry.name : `${base}/${entry.name}`;\n if (entry.isDirectory()) out.push(...(await walk(join(dir, entry.name), key)));\n else out.push({ key, size: (await stat(join(dir, entry.name))).size });\n }\n return out;\n };\n return {\n async put(key, bytes) {\n const path = pathOf(key);\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, bytes);\n },\n async get(key) {\n try {\n return { bytes: new Uint8Array(await readFile(pathOf(key))), contentType: typeOfName(key) };\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\" || (error as NodeJS.ErrnoException).code === \"EISDIR\") return null;\n throw error;\n }\n },\n async head(key) {\n try {\n const s = await stat(pathOf(key));\n return s.isFile() ? { key, size: s.size } : null;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return null;\n throw error;\n }\n },\n async delete(key) {\n await rm(pathOf(key), { force: true });\n },\n async list(prefix) {\n const slash = prefix.lastIndexOf(\"/\");\n const dir = slash < 0 ? \"\" : prefix.slice(0, slash);\n const found = await walk(dir === \"\" ? root : join(root, dir), dir);\n return found.filter((f) => f.key.startsWith(prefix)).sort((a, b) => a.key.localeCompare(b.key));\n },\n async signedUrl() {\n return null;\n },\n publicUrl: (key) => (options.publicBaseUrl ? `${trimSlashes(options.publicBaseUrl, \"end\")}/${assertKey(key)}` : null),\n };\n}\n","import { createHash } from \"node:crypto\";\n\nimport { assertKey, trimSlashes, typeOfName } from \"./keys\";\nimport { type Clock, type FetchLike, type ObjectInfo, type ObjectStore, STORAGE_CODE, StorageError } from \"./port\";\n\nexport interface RemoteStoreOptions {\n /** The storage API host (`storage.bunnycdn.com`, or a region's). */\n endpoint: string;\n /** The zone's name, the first segment of every path. */\n zone: string;\n /** The zone's password, sent as the `AccessKey` header. Never logged, never in a link we hand out. */\n accessKey: string;\n /** Every object lives under this prefix (no slashes at the ends), or none. */\n prefix?: string;\n /** The pull zone: with it, `publicUrl` answers; with its token key too, `signedUrl` does. */\n pullHostname?: string;\n tokenKey?: string;\n fetch: FetchLike;\n clock: Clock;\n}\n\ninterface Entry {\n ObjectName: string;\n IsDirectory: boolean;\n Length?: number;\n}\n\nconst fail = (what: string, status: number) => new StorageError(STORAGE_CODE.provider, `the storage refused the ${what} (${status})`);\n\n/**\n * An HTTP object store with the shape of Bunny Storage: `PUT`/`GET`/`DELETE` on `https://<endpoint>/<zone>/<prefix>/<key>` with an\n * `AccessKey` header, a directory listed by its path with a trailing slash (a JSON array of `ObjectName`, `IsDirectory`, `Length`),\n * and a pull zone for public and token-signed links. Reads and writes go through the authenticated API, never the public zone.\n */\nexport function remoteStore(options: RemoteStoreOptions): ObjectStore {\n const prefix = trimSlashes(options.prefix ?? \"\");\n const inZone = (key: string) => (prefix === \"\" ? \"\" : `${prefix}/`) + key;\n const base = `https://${options.endpoint}/${options.zone}`;\n const headers = { AccessKey: options.accessKey };\n const url = (key: string) => `${base}/${inZone(assertKey(key))}`;\n const listDir = async (dir: string): Promise<ObjectInfo[]> => {\n const root = prefix === \"\" ? \"\" : `${prefix}/`;\n const res = await options.fetch(dir === \"\" ? `${base}/${root}` : `${base}/${inZone(dir)}/`, { method: \"GET\", headers });\n if (res.status === 404) return [];\n if (!res.ok) throw fail(\"listing\", res.status);\n const entries = JSON.parse(await res.text()) as Entry[];\n const out: ObjectInfo[] = [];\n for (const e of entries) {\n const key = dir === \"\" ? e.ObjectName : `${dir}/${e.ObjectName}`;\n if (e.IsDirectory) out.push(...(await listDir(key)));\n else out.push({ key, size: e.Length ?? 0 });\n }\n return out;\n };\n const list = async (p: string) => {\n const slash = p.lastIndexOf(\"/\");\n return (await listDir(slash < 0 ? \"\" : p.slice(0, slash))).filter((f) => f.key.startsWith(p)).sort((a, b) => a.key.localeCompare(b.key));\n };\n return {\n async put(key, bytes, meta) {\n const res = await options.fetch(url(key), { method: \"PUT\", headers: { ...headers, \"content-type\": meta.contentType }, body: bytes });\n if (!res.ok) throw fail(\"write\", res.status);\n },\n async get(key) {\n const res = await options.fetch(url(key), { method: \"GET\", headers });\n if (res.status === 404) return null;\n if (!res.ok) throw fail(\"read\", res.status);\n return { bytes: new Uint8Array(await res.arrayBuffer()), contentType: res.headers.get(\"content-type\") ?? typeOfName(key) };\n },\n async head(key) {\n assertKey(key);\n return (await list(key)).find((f) => f.key === key) ?? null;\n },\n async delete(key) {\n const res = await options.fetch(url(key), { method: \"DELETE\", headers });\n if (!res.ok && res.status !== 404) throw fail(\"delete\", res.status);\n },\n list,\n async signedUrl(key, ttlSeconds) {\n if (!options.pullHostname || !options.tokenKey) return null;\n const path = `/${inZone(assertKey(key))}`;\n const expires = Math.floor(options.clock.now().valueOf() / 1000) + ttlSeconds;\n const token = createHash(\"sha256\").update(`${options.tokenKey}${path}${expires}`).digest(\"base64\").replaceAll(\"+\", \"-\").replaceAll(\"/\", \"_\").replaceAll(\"=\", \"\");\n return `https://${options.pullHostname}${path}?token=${token}&expires=${expires}`;\n },\n publicUrl: (key) => (options.pullHostname ? `https://${options.pullHostname}/${inZone(assertKey(key))}` : null),\n };\n}\n"],"mappings":"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","names":["createHash","fail","createHash"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { F as FetchLike, O as ObjectStore } from './port-CUTAx9CD.js';
|
|
2
|
+
export { a as ObjectInfo, S as STORAGE_CODE, b as StorageCode, c as StorageError, d as StoredObject } from './port-CUTAx9CD.js';
|
|
3
|
+
import { Clock } from '@krizaka/db';
|
|
4
|
+
export { Clock } from '@krizaka/db';
|
|
5
|
+
export { L as Layout, S as Storage, a as StorageDeps, b as StorageLimits, U as UploadInput, c as assertKey, d as createStorage, e as defineLayout, h as hashOf, f as hashedName, t as trimSlashes, g as typeOfName } from './storage-7cUuR_Z5.js';
|
|
6
|
+
|
|
7
|
+
interface BucketStoreOptions {
|
|
8
|
+
/** The provider's origin: `https://tor1.digitaloceanspaces.com`, `https://s3.example.com`, `http://localhost:9000`. */
|
|
9
|
+
endpoint: string;
|
|
10
|
+
region: string;
|
|
11
|
+
bucket: string;
|
|
12
|
+
accessKeyId: string;
|
|
13
|
+
secretAccessKey: string;
|
|
14
|
+
/** Every object lives under this prefix (no slashes at the ends), or none. */
|
|
15
|
+
prefix?: string;
|
|
16
|
+
/** Where the bucket is served publicly (a CDN in front of it), or none: the bucket is private. */
|
|
17
|
+
publicBaseUrl?: string;
|
|
18
|
+
/** Ask the provider to encrypt at rest (`AES256`). */
|
|
19
|
+
serverSideEncryption?: boolean;
|
|
20
|
+
fetch: FetchLike;
|
|
21
|
+
clock: Clock;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Any S3-compatible service (Spaces, a customer's bucket, MinIO) over plain HTTP: path-style URLs and Signature V4, no SDK.
|
|
25
|
+
* `signedUrl` is a presigned GET.
|
|
26
|
+
*/
|
|
27
|
+
declare function bucketStore(options: BucketStoreOptions): ObjectStore;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An endpoint an administrator typed (their own bucket) is called by our server: it must not reach inside our network.
|
|
31
|
+
* Refuses a non-https scheme, credentials in the URL, `localhost`, and literal private, loopback and link-local addresses.
|
|
32
|
+
* A name that RESOLVES to a private address is not caught here: the product's network egress rules are the second guard.
|
|
33
|
+
* `allowLocal` (development, MinIO on the Mac) lets `http` and local addresses through.
|
|
34
|
+
* @returns the origin, without a trailing slash
|
|
35
|
+
* @throws StorageError `STORAGE_BAD_ENDPOINT`
|
|
36
|
+
*/
|
|
37
|
+
declare function checkEndpoint(endpoint: string, options?: {
|
|
38
|
+
allowLocal?: boolean;
|
|
39
|
+
}): string;
|
|
40
|
+
|
|
41
|
+
interface FolderOptions {
|
|
42
|
+
/** The folder every file lives under. */
|
|
43
|
+
root: string;
|
|
44
|
+
/** Where the folder is served from, when it is (a dev server): the public link. */
|
|
45
|
+
publicBaseUrl?: string;
|
|
46
|
+
}
|
|
47
|
+
/** A folder on disk, for development, tests and the Mac apps. A key can never leave the root. */
|
|
48
|
+
declare function folderStore(options: FolderOptions): ObjectStore;
|
|
49
|
+
|
|
50
|
+
/** An in-memory store, for unit tests and demos: the behaviour of the others (the contract kit runs on all). */
|
|
51
|
+
declare function memoryObjectStore(options?: {
|
|
52
|
+
publicBaseUrl?: string;
|
|
53
|
+
}): ObjectStore;
|
|
54
|
+
|
|
55
|
+
interface RemoteStoreOptions {
|
|
56
|
+
/** The storage API host (`storage.bunnycdn.com`, or a region's). */
|
|
57
|
+
endpoint: string;
|
|
58
|
+
/** The zone's name, the first segment of every path. */
|
|
59
|
+
zone: string;
|
|
60
|
+
/** The zone's password, sent as the `AccessKey` header. Never logged, never in a link we hand out. */
|
|
61
|
+
accessKey: string;
|
|
62
|
+
/** Every object lives under this prefix (no slashes at the ends), or none. */
|
|
63
|
+
prefix?: string;
|
|
64
|
+
/** The pull zone: with it, `publicUrl` answers; with its token key too, `signedUrl` does. */
|
|
65
|
+
pullHostname?: string;
|
|
66
|
+
tokenKey?: string;
|
|
67
|
+
fetch: FetchLike;
|
|
68
|
+
clock: Clock;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* An HTTP object store with the shape of Bunny Storage: `PUT`/`GET`/`DELETE` on `https://<endpoint>/<zone>/<prefix>/<key>` with an
|
|
72
|
+
* `AccessKey` header, a directory listed by its path with a trailing slash (a JSON array of `ObjectName`, `IsDirectory`, `Length`),
|
|
73
|
+
* and a pull zone for public and token-signed links. Reads and writes go through the authenticated API, never the public zone.
|
|
74
|
+
*/
|
|
75
|
+
declare function remoteStore(options: RemoteStoreOptions): ObjectStore;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The content type the BYTES say they are, by magic number, never by the file name: png, jpeg, gif, webp, pdf, mp4, webm and svg.
|
|
79
|
+
* `null` when the bytes are none of these. An svg is text that opens with `<svg` (or an xml declaration followed by it).
|
|
80
|
+
*/
|
|
81
|
+
declare function sniffType(bytes: Uint8Array): string | null;
|
|
82
|
+
|
|
83
|
+
export { type BucketStoreOptions, FetchLike, type FolderOptions, ObjectStore, type RemoteStoreOptions, bucketStore, checkEndpoint, folderStore, memoryObjectStore, remoteStore, sniffType };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
STORAGE_CODE,
|
|
3
|
+
StorageError,
|
|
4
|
+
assertKey,
|
|
5
|
+
bucketStore,
|
|
6
|
+
checkEndpoint,
|
|
7
|
+
defineLayout,
|
|
8
|
+
folderStore,
|
|
9
|
+
hashOf,
|
|
10
|
+
hashedName,
|
|
11
|
+
remoteStore,
|
|
12
|
+
trimSlashes,
|
|
13
|
+
typeOfName
|
|
14
|
+
} from "./chunk-3E2PZNBG.js";
|
|
15
|
+
|
|
16
|
+
// src/memory-store.ts
|
|
17
|
+
function memoryObjectStore(options = {}) {
|
|
18
|
+
const files = /* @__PURE__ */ new Map();
|
|
19
|
+
return {
|
|
20
|
+
async put(key, bytes, meta) {
|
|
21
|
+
files.set(assertKey(key), { bytes: new Uint8Array(bytes), contentType: meta.contentType });
|
|
22
|
+
},
|
|
23
|
+
async get(key) {
|
|
24
|
+
const f = files.get(assertKey(key));
|
|
25
|
+
return f ? { bytes: new Uint8Array(f.bytes), contentType: f.contentType || typeOfName(key) } : null;
|
|
26
|
+
},
|
|
27
|
+
async head(key) {
|
|
28
|
+
const f = files.get(assertKey(key));
|
|
29
|
+
return f ? { key, size: f.bytes.byteLength } : null;
|
|
30
|
+
},
|
|
31
|
+
async delete(key) {
|
|
32
|
+
files.delete(assertKey(key));
|
|
33
|
+
},
|
|
34
|
+
async list(prefix) {
|
|
35
|
+
return [...files.entries()].filter(([key]) => key.startsWith(prefix)).map(([key, f]) => ({ key, size: f.bytes.byteLength })).sort((a, b) => a.key.localeCompare(b.key));
|
|
36
|
+
},
|
|
37
|
+
async signedUrl() {
|
|
38
|
+
return null;
|
|
39
|
+
},
|
|
40
|
+
publicUrl: (key) => options.publicBaseUrl ? `${trimSlashes(options.publicBaseUrl, "end")}/${assertKey(key)}` : null
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/sniff.ts
|
|
45
|
+
var startsWith = (bytes, signature, offset = 0) => signature.every((b, i) => bytes[offset + i] === b);
|
|
46
|
+
var ascii = (bytes, from, to) => String.fromCharCode(...bytes.subarray(from, to));
|
|
47
|
+
function sniffType(bytes) {
|
|
48
|
+
if (startsWith(bytes, [137, 80, 78, 71, 13, 10, 26, 10])) return "image/png";
|
|
49
|
+
if (startsWith(bytes, [255, 216, 255])) return "image/jpeg";
|
|
50
|
+
if (ascii(bytes, 0, 4) === "GIF8") return "image/gif";
|
|
51
|
+
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
|
|
52
|
+
if (ascii(bytes, 0, 5) === "%PDF-") return "application/pdf";
|
|
53
|
+
if (ascii(bytes, 4, 8) === "ftyp") return "video/mp4";
|
|
54
|
+
if (startsWith(bytes, [26, 69, 223, 163])) return "video/webm";
|
|
55
|
+
const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, 512)).replace("\uFEFF", "").trimStart();
|
|
56
|
+
if (startsSvg(head)) return "image/svg+xml";
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function startsSvg(text) {
|
|
60
|
+
let rest = text;
|
|
61
|
+
if (rest.startsWith("<?xml")) {
|
|
62
|
+
const end = rest.indexOf("?>");
|
|
63
|
+
if (end < 0) return false;
|
|
64
|
+
rest = rest.slice(end + 2).trimStart();
|
|
65
|
+
}
|
|
66
|
+
while (rest.startsWith("<!--")) {
|
|
67
|
+
const end = rest.indexOf("-->");
|
|
68
|
+
if (end < 0) return false;
|
|
69
|
+
rest = rest.slice(end + 3).trimStart();
|
|
70
|
+
}
|
|
71
|
+
return /^<svg[\s>]/i.test(rest);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/storage.ts
|
|
75
|
+
function createStorage(deps) {
|
|
76
|
+
const limits = async () => typeof deps.limits === "function" ? await deps.limits() : deps.limits;
|
|
77
|
+
const urlFor = async (key) => deps.store.publicUrl(key) ?? await deps.store.signedUrl(key, (await limits()).signedUrlSeconds);
|
|
78
|
+
return {
|
|
79
|
+
async upload(input) {
|
|
80
|
+
const { maxBytes } = await limits();
|
|
81
|
+
if (input.bytes.byteLength === 0 || input.bytes.byteLength > maxBytes) throw new StorageError(STORAGE_CODE.tooBig, `a file is 1 to ${maxBytes} bytes`);
|
|
82
|
+
const actual = sniffType(input.bytes);
|
|
83
|
+
if (actual === null || !input.allow.includes(actual)) throw new StorageError(STORAGE_CODE.badType, `the bytes are ${actual ?? "of an unknown type"}, not one of ${input.allow.join(", ")}`);
|
|
84
|
+
if (input.declaredType !== void 0 && input.declaredType !== actual) throw new StorageError(STORAGE_CODE.typeMismatch, `declared ${input.declaredType}, the bytes are ${actual}`);
|
|
85
|
+
const key = deps.layout.check(`${input.prefix}${hashedName(input.name, input.bytes)}`);
|
|
86
|
+
if (deps.layout.prefixOf(key) !== input.prefix) throw new StorageError(STORAGE_CODE.badPrefix, `\xAB ${input.prefix} \xBB is not a declared prefix`);
|
|
87
|
+
await deps.store.put(key, input.bytes, { contentType: actual });
|
|
88
|
+
return { key, contentType: actual, size: input.bytes.byteLength, url: await urlFor(key) };
|
|
89
|
+
},
|
|
90
|
+
urlFor,
|
|
91
|
+
get: (key) => deps.store.get(key),
|
|
92
|
+
head: (key) => deps.store.head(key),
|
|
93
|
+
delete: (key) => deps.store.delete(key),
|
|
94
|
+
list: (prefix) => deps.store.list(prefix)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
STORAGE_CODE,
|
|
99
|
+
StorageError,
|
|
100
|
+
assertKey,
|
|
101
|
+
bucketStore,
|
|
102
|
+
checkEndpoint,
|
|
103
|
+
createStorage,
|
|
104
|
+
defineLayout,
|
|
105
|
+
folderStore,
|
|
106
|
+
hashOf,
|
|
107
|
+
hashedName,
|
|
108
|
+
memoryObjectStore,
|
|
109
|
+
remoteStore,
|
|
110
|
+
sniffType,
|
|
111
|
+
trimSlashes,
|
|
112
|
+
typeOfName
|
|
113
|
+
};
|
|
114
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/memory-store.ts","../src/sniff.ts","../src/storage.ts"],"sourcesContent":["import { assertKey, trimSlashes, typeOfName } from \"./keys\";\nimport type { ObjectStore } from \"./port\";\n\n/** An in-memory store, for unit tests and demos: the behaviour of the others (the contract kit runs on all). */\nexport function memoryObjectStore(options: { publicBaseUrl?: string } = {}): ObjectStore {\n const files = new Map<string, { bytes: Uint8Array; contentType: string }>();\n return {\n async put(key, bytes, meta) {\n files.set(assertKey(key), { bytes: new Uint8Array(bytes), contentType: meta.contentType });\n },\n async get(key) {\n const f = files.get(assertKey(key));\n return f ? { bytes: new Uint8Array(f.bytes), contentType: f.contentType || typeOfName(key) } : null;\n },\n async head(key) {\n const f = files.get(assertKey(key));\n return f ? { key, size: f.bytes.byteLength } : null;\n },\n async delete(key) {\n files.delete(assertKey(key));\n },\n async list(prefix) {\n return [...files.entries()]\n .filter(([key]) => key.startsWith(prefix))\n .map(([key, f]) => ({ key, size: f.bytes.byteLength }))\n .sort((a, b) => a.key.localeCompare(b.key));\n },\n async signedUrl() {\n return null;\n },\n publicUrl: (key) => (options.publicBaseUrl ? `${trimSlashes(options.publicBaseUrl, \"end\")}/${assertKey(key)}` : null),\n };\n}\n","const startsWith = (bytes: Uint8Array, signature: readonly number[], offset = 0) => signature.every((b, i) => bytes[offset + i] === b);\nconst ascii = (bytes: Uint8Array, from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to));\n\n/**\n * The content type the BYTES say they are, by magic number, never by the file name: png, jpeg, gif, webp, pdf, mp4, webm and svg.\n * `null` when the bytes are none of these. An svg is text that opens with `<svg` (or an xml declaration followed by it).\n */\nexport function sniffType(bytes: Uint8Array): string | null {\n if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return \"image/png\";\n if (startsWith(bytes, [0xff, 0xd8, 0xff])) return \"image/jpeg\";\n if (ascii(bytes, 0, 4) === \"GIF8\") return \"image/gif\";\n if (ascii(bytes, 0, 4) === \"RIFF\" && ascii(bytes, 8, 12) === \"WEBP\") return \"image/webp\";\n if (ascii(bytes, 0, 5) === \"%PDF-\") return \"application/pdf\";\n if (ascii(bytes, 4, 8) === \"ftyp\") return \"video/mp4\";\n if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return \"video/webm\";\n const head = new TextDecoder(\"utf-8\", { fatal: false }).decode(bytes.subarray(0, 512)).replace(\"\\uFEFF\", \"\").trimStart();\n if (startsSvg(head)) return \"image/svg+xml\";\n return null;\n}\n\n/** Whether text opens as an SVG document: an optional XML declaration and comments, then `<svg`. Read with indexOf, never a backtracking pattern. */\nfunction startsSvg(text: string): boolean {\n let rest = text;\n if (rest.startsWith(\"<?xml\")) {\n const end = rest.indexOf(\"?>\");\n if (end < 0) return false;\n rest = rest.slice(end + 2).trimStart();\n }\n while (rest.startsWith(\"<!--\")) {\n const end = rest.indexOf(\"-->\");\n if (end < 0) return false;\n rest = rest.slice(end + 3).trimStart();\n }\n return /^<svg[\\s>]/i.test(rest);\n}\n","import { hashedName, type Layout } from \"./keys\";\nimport { type ObjectStore, STORAGE_CODE, StorageError } from \"./port\";\nimport { sniffType } from \"./sniff\";\n\nexport interface StorageLimits {\n /** The largest file accepted, in bytes. */\n maxBytes: number;\n /** Seconds a signed link lives. */\n signedUrlSeconds: number;\n}\n\nexport interface StorageDeps {\n store: ObjectStore;\n layout: Layout;\n /** Read at each call so the admin's change applies at once. */\n limits: StorageLimits | (() => StorageLimits | Promise<StorageLimits>);\n}\n\nexport interface UploadInput {\n /** The layout prefix the file goes under (`site/home/`). */\n prefix: string;\n /** The name the file arrives with; the stored name is `<name>.<sha10>.<ext>`. */\n name: string;\n bytes: Uint8Array;\n /** The content types this upload may be: the BYTES must be one of them, whatever the name or the declared type say. */\n allow: readonly string[];\n /** The type the client declared, when it did: it must agree with the bytes. */\n declaredType?: string;\n}\n\nexport interface Storage {\n /** Checks the size, the prefix and the bytes' magic number, then stores the file under a content-hashed name. Answers its key, and a link when one exists. */\n upload(input: UploadInput): Promise<{ key: string; contentType: string; size: number; url: string | null }>;\n /** The public link when the zone has one, else a signed link, else `null`. */\n urlFor(key: string): Promise<string | null>;\n get: ObjectStore[\"get\"];\n head: ObjectStore[\"head\"];\n delete: ObjectStore[\"delete\"];\n list: ObjectStore[\"list\"];\n}\n\n/**\n * The storage of a product: an `ObjectStore` with the rules every upload obeys (a size limit passed in, a declared layout, bytes\n * checked by magic number, a content-hashed name).\n */\nexport function createStorage(deps: StorageDeps): Storage {\n const limits = async () => (typeof deps.limits === \"function\" ? await deps.limits() : deps.limits);\n const urlFor = async (key: string) => deps.store.publicUrl(key) ?? (await deps.store.signedUrl(key, (await limits()).signedUrlSeconds));\n return {\n async upload(input) {\n const { maxBytes } = await limits();\n if (input.bytes.byteLength === 0 || input.bytes.byteLength > maxBytes) throw new StorageError(STORAGE_CODE.tooBig, `a file is 1 to ${maxBytes} bytes`);\n const actual = sniffType(input.bytes);\n if (actual === null || !input.allow.includes(actual)) throw new StorageError(STORAGE_CODE.badType, `the bytes are ${actual ?? \"of an unknown type\"}, not one of ${input.allow.join(\", \")}`);\n if (input.declaredType !== undefined && input.declaredType !== actual) throw new StorageError(STORAGE_CODE.typeMismatch, `declared ${input.declaredType}, the bytes are ${actual}`);\n const key = deps.layout.check(`${input.prefix}${hashedName(input.name, input.bytes)}`);\n if (deps.layout.prefixOf(key) !== input.prefix) throw new StorageError(STORAGE_CODE.badPrefix, `« ${input.prefix} » is not a declared prefix`);\n await deps.store.put(key, input.bytes, { contentType: actual });\n return { key, contentType: actual, size: input.bytes.byteLength, url: await urlFor(key) };\n },\n urlFor,\n get: (key) => deps.store.get(key),\n head: (key) => deps.store.head(key),\n delete: (key) => deps.store.delete(key),\n list: (prefix) => deps.store.list(prefix),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAIO,SAAS,kBAAkB,UAAsC,CAAC,GAAgB;AACvF,QAAM,QAAQ,oBAAI,IAAwD;AAC1E,SAAO;AAAA,IACL,MAAM,IAAI,KAAK,OAAO,MAAM;AAC1B,YAAM,IAAI,UAAU,GAAG,GAAG,EAAE,OAAO,IAAI,WAAW,KAAK,GAAG,aAAa,KAAK,YAAY,CAAC;AAAA,IAC3F;AAAA,IACA,MAAM,IAAI,KAAK;AACb,YAAM,IAAI,MAAM,IAAI,UAAU,GAAG,CAAC;AAClC,aAAO,IAAI,EAAE,OAAO,IAAI,WAAW,EAAE,KAAK,GAAG,aAAa,EAAE,eAAe,WAAW,GAAG,EAAE,IAAI;AAAA,IACjG;AAAA,IACA,MAAM,KAAK,KAAK;AACd,YAAM,IAAI,MAAM,IAAI,UAAU,GAAG,CAAC;AAClC,aAAO,IAAI,EAAE,KAAK,MAAM,EAAE,MAAM,WAAW,IAAI;AAAA,IACjD;AAAA,IACA,MAAM,OAAO,KAAK;AAChB,YAAM,OAAO,UAAU,GAAG,CAAC;AAAA,IAC7B;AAAA,IACA,MAAM,KAAK,QAAQ;AACjB,aAAO,CAAC,GAAG,MAAM,QAAQ,CAAC,EACvB,OAAO,CAAC,CAAC,GAAG,MAAM,IAAI,WAAW,MAAM,CAAC,EACxC,IAAI,CAAC,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,WAAW,EAAE,EACrD,KAAK,CAAC,GAAG,MAAM,EAAE,IAAI,cAAc,EAAE,GAAG,CAAC;AAAA,IAC9C;AAAA,IACA,MAAM,YAAY;AAChB,aAAO;AAAA,IACT;AAAA,IACA,WAAW,CAAC,QAAS,QAAQ,gBAAgB,GAAG,YAAY,QAAQ,eAAe,KAAK,CAAC,IAAI,UAAU,GAAG,CAAC,KAAK;AAAA,EAClH;AACF;;;AChCA,IAAM,aAAa,CAAC,OAAmB,WAA8B,SAAS,MAAM,UAAU,MAAM,CAAC,GAAG,MAAM,MAAM,SAAS,CAAC,MAAM,CAAC;AACrI,IAAM,QAAQ,CAAC,OAAmB,MAAc,OAAe,OAAO,aAAa,GAAG,MAAM,SAAS,MAAM,EAAE,CAAC;AAMvG,SAAS,UAAU,OAAkC;AAC1D,MAAI,WAAW,OAAO,CAAC,KAAM,IAAM,IAAM,IAAM,IAAM,IAAM,IAAM,EAAI,CAAC,EAAG,QAAO;AAChF,MAAI,WAAW,OAAO,CAAC,KAAM,KAAM,GAAI,CAAC,EAAG,QAAO;AAClD,MAAI,MAAM,OAAO,GAAG,CAAC,MAAM,OAAQ,QAAO;AAC1C,MAAI,MAAM,OAAO,GAAG,CAAC,MAAM,UAAU,MAAM,OAAO,GAAG,EAAE,MAAM,OAAQ,QAAO;AAC5E,MAAI,MAAM,OAAO,GAAG,CAAC,MAAM,QAAS,QAAO;AAC3C,MAAI,MAAM,OAAO,GAAG,CAAC,MAAM,OAAQ,QAAO;AAC1C,MAAI,WAAW,OAAO,CAAC,IAAM,IAAM,KAAM,GAAI,CAAC,EAAG,QAAO;AACxD,QAAM,OAAO,IAAI,YAAY,SAAS,EAAE,OAAO,MAAM,CAAC,EAAE,OAAO,MAAM,SAAS,GAAG,GAAG,CAAC,EAAE,QAAQ,UAAU,EAAE,EAAE,UAAU;AACvH,MAAI,UAAU,IAAI,EAAG,QAAO;AAC5B,SAAO;AACT;AAGA,SAAS,UAAU,MAAuB;AACxC,MAAI,OAAO;AACX,MAAI,KAAK,WAAW,OAAO,GAAG;AAC5B,UAAM,MAAM,KAAK,QAAQ,IAAI;AAC7B,QAAI,MAAM,EAAG,QAAO;AACpB,WAAO,KAAK,MAAM,MAAM,CAAC,EAAE,UAAU;AAAA,EACvC;AACA,SAAO,KAAK,WAAW,MAAM,GAAG;AAC9B,UAAM,MAAM,KAAK,QAAQ,KAAK;AAC9B,QAAI,MAAM,EAAG,QAAO;AACpB,WAAO,KAAK,MAAM,MAAM,CAAC,EAAE,UAAU;AAAA,EACvC;AACA,SAAO,cAAc,KAAK,IAAI;AAChC;;;ACWO,SAAS,cAAc,MAA4B;AACxD,QAAM,SAAS,YAAa,OAAO,KAAK,WAAW,aAAa,MAAM,KAAK,OAAO,IAAI,KAAK;AAC3F,QAAM,SAAS,OAAO,QAAgB,KAAK,MAAM,UAAU,GAAG,KAAM,MAAM,KAAK,MAAM,UAAU,MAAM,MAAM,OAAO,GAAG,gBAAgB;AACrI,SAAO;AAAA,IACL,MAAM,OAAO,OAAO;AAClB,YAAM,EAAE,SAAS,IAAI,MAAM,OAAO;AAClC,UAAI,MAAM,MAAM,eAAe,KAAK,MAAM,MAAM,aAAa,SAAU,OAAM,IAAI,aAAa,aAAa,QAAQ,kBAAkB,QAAQ,QAAQ;AACrJ,YAAM,SAAS,UAAU,MAAM,KAAK;AACpC,UAAI,WAAW,QAAQ,CAAC,MAAM,MAAM,SAAS,MAAM,EAAG,OAAM,IAAI,aAAa,aAAa,SAAS,iBAAiB,UAAU,oBAAoB,gBAAgB,MAAM,MAAM,KAAK,IAAI,CAAC,EAAE;AAC1L,UAAI,MAAM,iBAAiB,UAAa,MAAM,iBAAiB,OAAQ,OAAM,IAAI,aAAa,aAAa,cAAc,YAAY,MAAM,YAAY,mBAAmB,MAAM,EAAE;AAClL,YAAM,MAAM,KAAK,OAAO,MAAM,GAAG,MAAM,MAAM,GAAG,WAAW,MAAM,MAAM,MAAM,KAAK,CAAC,EAAE;AACrF,UAAI,KAAK,OAAO,SAAS,GAAG,MAAM,MAAM,OAAQ,OAAM,IAAI,aAAa,aAAa,WAAW,QAAK,MAAM,MAAM,gCAA6B;AAC7I,YAAM,KAAK,MAAM,IAAI,KAAK,MAAM,OAAO,EAAE,aAAa,OAAO,CAAC;AAC9D,aAAO,EAAE,KAAK,aAAa,QAAQ,MAAM,MAAM,MAAM,YAAY,KAAK,MAAM,OAAO,GAAG,EAAE;AAAA,IAC1F;AAAA,IACA;AAAA,IACA,KAAK,CAAC,QAAQ,KAAK,MAAM,IAAI,GAAG;AAAA,IAChC,MAAM,CAAC,QAAQ,KAAK,MAAM,KAAK,GAAG;AAAA,IAClC,QAAQ,CAAC,QAAQ,KAAK,MAAM,OAAO,GAAG;AAAA,IACtC,MAAM,CAAC,WAAW,KAAK,MAAM,KAAK,MAAM;AAAA,EAC1C;AACF;","names":[]}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ports of « storage »: files behind one interface, named by what it does. Generic: no vendor SDK (adapters speak plain HTTP
|
|
3
|
+
* through an injected `fetch`), no environment variable (credentials come in as parameters), no wall clock (a `Clock` signs the links).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The part of `fetch` the adapters use: inject the global one (`fetch`), or a fake in a test. */
|
|
7
|
+
type FetchLike = (url: string, init: {
|
|
8
|
+
method: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body?: Uint8Array;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
ok: boolean;
|
|
13
|
+
status: number;
|
|
14
|
+
headers: {
|
|
15
|
+
get(name: string): string | null;
|
|
16
|
+
};
|
|
17
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
18
|
+
text(): Promise<string>;
|
|
19
|
+
}>;
|
|
20
|
+
interface StoredObject {
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
contentType: string;
|
|
23
|
+
}
|
|
24
|
+
interface ObjectInfo {
|
|
25
|
+
key: string;
|
|
26
|
+
size: number;
|
|
27
|
+
}
|
|
28
|
+
/** One place files live. Every adapter runs `objectStoreContract` (`@krizaka/storage/testkit`). Keys are `a/b/name.ext`, checked by `assertKey`. */
|
|
29
|
+
interface ObjectStore {
|
|
30
|
+
put(key: string, bytes: Uint8Array, meta: {
|
|
31
|
+
contentType: string;
|
|
32
|
+
}): Promise<void>;
|
|
33
|
+
/** `null` when there is no such file. */
|
|
34
|
+
get(key: string): Promise<StoredObject | null>;
|
|
35
|
+
head(key: string): Promise<ObjectInfo | null>;
|
|
36
|
+
/** Idempotent: deleting a missing file is not an error. */
|
|
37
|
+
delete(key: string): Promise<void>;
|
|
38
|
+
/** Every file whose key starts with `prefix`, in key order, directories flattened. */
|
|
39
|
+
list(prefix: string): Promise<ObjectInfo[]>;
|
|
40
|
+
/** A link valid for `ttlSeconds` for a private file, or `null` when the store cannot sign one. */
|
|
41
|
+
signedUrl(key: string, ttlSeconds: number): Promise<string | null>;
|
|
42
|
+
/** The permanent public link (a pull zone, a CDN), or `null` when the zone is private. */
|
|
43
|
+
publicUrl(key: string): string | null;
|
|
44
|
+
}
|
|
45
|
+
/** The refusals of this package: a stable `code`, and the HTTP status a route would answer. */
|
|
46
|
+
declare const STORAGE_CODE: {
|
|
47
|
+
readonly badKey: "STORAGE_BAD_KEY";
|
|
48
|
+
readonly badPrefix: "STORAGE_BAD_PREFIX";
|
|
49
|
+
readonly tooBig: "STORAGE_TOO_BIG";
|
|
50
|
+
readonly badType: "STORAGE_BAD_TYPE";
|
|
51
|
+
readonly typeMismatch: "STORAGE_TYPE_MISMATCH";
|
|
52
|
+
readonly provider: "STORAGE_PROVIDER_FAILED";
|
|
53
|
+
readonly badConfig: "STORAGE_BAD_CONFIG";
|
|
54
|
+
readonly badEndpoint: "STORAGE_BAD_ENDPOINT";
|
|
55
|
+
};
|
|
56
|
+
type StorageCode = (typeof STORAGE_CODE)[keyof typeof STORAGE_CODE];
|
|
57
|
+
declare class StorageError extends Error {
|
|
58
|
+
readonly code: StorageCode;
|
|
59
|
+
constructor(code: StorageCode, message: string);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { type FetchLike as F, type ObjectStore as O, STORAGE_CODE as S, type ObjectInfo as a, type StorageCode as b, StorageError as c, type StoredObject as d };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as _krizaka_settings from '@krizaka/settings';
|
|
2
|
+
import { F as FetchLike, O as ObjectStore } from './port-CUTAx9CD.js';
|
|
3
|
+
import { b as StorageLimits } from './storage-7cUuR_Z5.js';
|
|
4
|
+
import { Clock } from '@krizaka/db';
|
|
5
|
+
|
|
6
|
+
declare const STORAGE_PROVIDERS: readonly ["local", "http", "s3"];
|
|
7
|
+
declare const STORAGE_DEFAULTS: {
|
|
8
|
+
readonly provider: "local";
|
|
9
|
+
readonly maxMegabytes: 25;
|
|
10
|
+
readonly signedUrlSeconds: 300;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The settings of « storage », a fragment the product adds to its registry (`@krizaka/storage/settings`): the admin picks the provider
|
|
14
|
+
* (`local`, `http` for a Bunny-style zone, `s3` for any S3-compatible bucket) and its non-secret coordinates, without a redeploy.
|
|
15
|
+
* Access keys and the token key stay in the environment and are passed to `storeFromSettings`.
|
|
16
|
+
*/
|
|
17
|
+
declare const storageSettings: {
|
|
18
|
+
readonly "storage.provider": _krizaka_settings.Def<"local" | "http" | "s3">;
|
|
19
|
+
readonly "storage.maxMegabytes": _krizaka_settings.Def<number>;
|
|
20
|
+
readonly "storage.signedUrlSeconds": _krizaka_settings.Def<number>;
|
|
21
|
+
readonly "storage.endpoint": _krizaka_settings.Def<string>;
|
|
22
|
+
readonly "storage.region": _krizaka_settings.Def<string>;
|
|
23
|
+
readonly "storage.bucket": _krizaka_settings.Def<string>;
|
|
24
|
+
readonly "storage.zone": _krizaka_settings.Def<string>;
|
|
25
|
+
readonly "storage.prefix": _krizaka_settings.Def<string>;
|
|
26
|
+
readonly "storage.pullHostname": _krizaka_settings.Def<string>;
|
|
27
|
+
readonly "storage.publicBaseUrl": _krizaka_settings.Def<string>;
|
|
28
|
+
};
|
|
29
|
+
interface StorageSettings {
|
|
30
|
+
provider: (typeof STORAGE_PROVIDERS)[number];
|
|
31
|
+
maxMegabytes: number;
|
|
32
|
+
signedUrlSeconds: number;
|
|
33
|
+
endpoint: string;
|
|
34
|
+
region: string;
|
|
35
|
+
bucket: string;
|
|
36
|
+
zone: string;
|
|
37
|
+
prefix: string;
|
|
38
|
+
pullHostname: string;
|
|
39
|
+
publicBaseUrl: string;
|
|
40
|
+
}
|
|
41
|
+
type Reader = (key: keyof typeof storageSettings) => string | number | undefined | Promise<string | number | undefined>;
|
|
42
|
+
/** The module's settings from the product's accessor (`(key) => service.get(key, ctx)`); an unknown key falls back to the default. */
|
|
43
|
+
declare function readStorageSettings(read: Reader): Promise<StorageSettings>;
|
|
44
|
+
declare const limitsOf: (s: StorageSettings) => StorageLimits;
|
|
45
|
+
/** The credentials the product reads from its environment and passes in. */
|
|
46
|
+
interface StorageSecrets {
|
|
47
|
+
accessKeyId?: string;
|
|
48
|
+
secretAccessKey?: string;
|
|
49
|
+
/** The zone's password (`http`). */
|
|
50
|
+
accessKey?: string;
|
|
51
|
+
/** The pull zone's token-authentication key (`http`). */
|
|
52
|
+
tokenKey?: string;
|
|
53
|
+
/** The folder for `local`. */
|
|
54
|
+
root?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The store the admin chose. A provider chosen without its coordinates or keys is refused loudly, never replaced by another: a file
|
|
58
|
+
* must not land somewhere nobody expects. The `http` endpoint is the host only (`storage.bunnycdn.com`); an `s3` endpoint is checked
|
|
59
|
+
* against our own network (`checkEndpoint`) unless `allowLocalEndpoint` (development).
|
|
60
|
+
* @throws StorageError `STORAGE_BAD_CONFIG` or `STORAGE_BAD_ENDPOINT`
|
|
61
|
+
*/
|
|
62
|
+
declare function storeFromSettings(s: StorageSettings, secrets: StorageSecrets, deps: {
|
|
63
|
+
fetch: FetchLike;
|
|
64
|
+
clock: Clock;
|
|
65
|
+
allowLocalEndpoint?: boolean;
|
|
66
|
+
}): ObjectStore;
|
|
67
|
+
|
|
68
|
+
export { STORAGE_DEFAULTS, STORAGE_PROVIDERS, type StorageSecrets, type StorageSettings, limitsOf, readStorageSettings, storageSettings, storeFromSettings };
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
STORAGE_CODE,
|
|
3
|
+
StorageError,
|
|
4
|
+
bucketStore,
|
|
5
|
+
checkEndpoint,
|
|
6
|
+
folderStore,
|
|
7
|
+
remoteStore
|
|
8
|
+
} from "./chunk-3E2PZNBG.js";
|
|
9
|
+
|
|
10
|
+
// src/settings.ts
|
|
11
|
+
import { choice, integer, text } from "@krizaka/settings";
|
|
12
|
+
var STORAGE_PROVIDERS = ["local", "http", "s3"];
|
|
13
|
+
var STORAGE_DEFAULTS = { provider: "local", maxMegabytes: 25, signedUrlSeconds: 300 };
|
|
14
|
+
var MB = 1024 * 1024;
|
|
15
|
+
var storageSettings = {
|
|
16
|
+
"storage.provider": choice("storage", STORAGE_PROVIDERS, STORAGE_DEFAULTS.provider),
|
|
17
|
+
"storage.maxMegabytes": integer("storage", STORAGE_DEFAULTS.maxMegabytes, { min: 1, max: 2048, unit: "MB" }),
|
|
18
|
+
"storage.signedUrlSeconds": integer("storage", STORAGE_DEFAULTS.signedUrlSeconds, { min: 30, max: 86400, unit: "seconds" }),
|
|
19
|
+
"storage.endpoint": text("storage", "", { empty: true }),
|
|
20
|
+
"storage.region": text("storage", "", { empty: true }),
|
|
21
|
+
"storage.bucket": text("storage", "", { empty: true }),
|
|
22
|
+
"storage.zone": text("storage", "", { empty: true }),
|
|
23
|
+
"storage.prefix": text("storage", "", { empty: true }),
|
|
24
|
+
"storage.pullHostname": text("storage", "", { empty: true }),
|
|
25
|
+
"storage.publicBaseUrl": text("storage", "", { empty: true })
|
|
26
|
+
};
|
|
27
|
+
async function readStorageSettings(read) {
|
|
28
|
+
const str = async (k) => {
|
|
29
|
+
const v = await read(k);
|
|
30
|
+
return typeof v === "string" ? v : "";
|
|
31
|
+
};
|
|
32
|
+
const provider = await read("storage.provider");
|
|
33
|
+
const maxMegabytes = await read("storage.maxMegabytes");
|
|
34
|
+
const signed = await read("storage.signedUrlSeconds");
|
|
35
|
+
return {
|
|
36
|
+
provider: STORAGE_PROVIDERS.find((p) => p === provider) ?? STORAGE_DEFAULTS.provider,
|
|
37
|
+
maxMegabytes: typeof maxMegabytes === "number" ? maxMegabytes : STORAGE_DEFAULTS.maxMegabytes,
|
|
38
|
+
signedUrlSeconds: typeof signed === "number" ? signed : STORAGE_DEFAULTS.signedUrlSeconds,
|
|
39
|
+
endpoint: await str("storage.endpoint"),
|
|
40
|
+
region: await str("storage.region"),
|
|
41
|
+
bucket: await str("storage.bucket"),
|
|
42
|
+
zone: await str("storage.zone"),
|
|
43
|
+
prefix: await str("storage.prefix"),
|
|
44
|
+
pullHostname: await str("storage.pullHostname"),
|
|
45
|
+
publicBaseUrl: await str("storage.publicBaseUrl")
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
var limitsOf = (s) => ({ maxBytes: s.maxMegabytes * MB, signedUrlSeconds: s.signedUrlSeconds });
|
|
49
|
+
function storeFromSettings(s, secrets, deps) {
|
|
50
|
+
const need = (value, what) => {
|
|
51
|
+
if (!value) throw new StorageError(STORAGE_CODE.badConfig, `storage.provider is ${s.provider} but ${what} is not set`);
|
|
52
|
+
return value;
|
|
53
|
+
};
|
|
54
|
+
const optional = (key, value) => value === "" ? {} : { [key]: value };
|
|
55
|
+
if (s.provider === "local") return folderStore({ root: need(secrets.root, "the folder"), ...optional("publicBaseUrl", s.publicBaseUrl) });
|
|
56
|
+
if (s.provider === "http") {
|
|
57
|
+
const endpoint = need(s.endpoint, "storage.endpoint");
|
|
58
|
+
if (/[/:]/.test(endpoint)) throw new StorageError(STORAGE_CODE.badEndpoint, "the http endpoint is a host name only");
|
|
59
|
+
return remoteStore({ endpoint, zone: need(s.zone, "storage.zone"), accessKey: need(secrets.accessKey, "the zone password"), ...optional("prefix", s.prefix), ...optional("pullHostname", s.pullHostname), ...optional("tokenKey", secrets.tokenKey ?? ""), ...deps });
|
|
60
|
+
}
|
|
61
|
+
return bucketStore({
|
|
62
|
+
endpoint: checkEndpoint(need(s.endpoint, "storage.endpoint"), deps.allowLocalEndpoint ? { allowLocal: true } : {}),
|
|
63
|
+
region: need(s.region, "storage.region"),
|
|
64
|
+
bucket: need(s.bucket, "storage.bucket"),
|
|
65
|
+
accessKeyId: need(secrets.accessKeyId, "the access key id"),
|
|
66
|
+
secretAccessKey: need(secrets.secretAccessKey, "the secret access key"),
|
|
67
|
+
...optional("prefix", s.prefix),
|
|
68
|
+
...optional("publicBaseUrl", s.publicBaseUrl),
|
|
69
|
+
...deps
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
STORAGE_DEFAULTS,
|
|
74
|
+
STORAGE_PROVIDERS,
|
|
75
|
+
limitsOf,
|
|
76
|
+
readStorageSettings,
|
|
77
|
+
storageSettings,
|
|
78
|
+
storeFromSettings
|
|
79
|
+
};
|
|
80
|
+
//# sourceMappingURL=settings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { choice, integer, type Registry, text } from \"@krizaka/settings\";\n\nimport { bucketStore } from \"./bucket-store\";\nimport { checkEndpoint } from \"./endpoint\";\nimport { folderStore } from \"./folder-store\";\nimport { type Clock, type FetchLike, type ObjectStore, STORAGE_CODE, StorageError } from \"./port\";\nimport { remoteStore } from \"./remote-store\";\nimport type { StorageLimits } from \"./storage\";\n\nexport const STORAGE_PROVIDERS = [\"local\", \"http\", \"s3\"] as const;\nexport const STORAGE_DEFAULTS = { provider: \"local\", maxMegabytes: 25, signedUrlSeconds: 300 } as const;\nconst MB = 1024 * 1024;\n\n/**\n * The settings of « storage », a fragment the product adds to its registry (`@krizaka/storage/settings`): the admin picks the provider\n * (`local`, `http` for a Bunny-style zone, `s3` for any S3-compatible bucket) and its non-secret coordinates, without a redeploy.\n * Access keys and the token key stay in the environment and are passed to `storeFromSettings`.\n */\nexport const storageSettings = {\n \"storage.provider\": choice(\"storage\", STORAGE_PROVIDERS, STORAGE_DEFAULTS.provider),\n \"storage.maxMegabytes\": integer(\"storage\", STORAGE_DEFAULTS.maxMegabytes, { min: 1, max: 2048, unit: \"MB\" }),\n \"storage.signedUrlSeconds\": integer(\"storage\", STORAGE_DEFAULTS.signedUrlSeconds, { min: 30, max: 86_400, unit: \"seconds\" }),\n \"storage.endpoint\": text(\"storage\", \"\", { empty: true }),\n \"storage.region\": text(\"storage\", \"\", { empty: true }),\n \"storage.bucket\": text(\"storage\", \"\", { empty: true }),\n \"storage.zone\": text(\"storage\", \"\", { empty: true }),\n \"storage.prefix\": text(\"storage\", \"\", { empty: true }),\n \"storage.pullHostname\": text(\"storage\", \"\", { empty: true }),\n \"storage.publicBaseUrl\": text(\"storage\", \"\", { empty: true }),\n} as const satisfies Registry;\n\nexport interface StorageSettings {\n provider: (typeof STORAGE_PROVIDERS)[number];\n maxMegabytes: number;\n signedUrlSeconds: number;\n endpoint: string;\n region: string;\n bucket: string;\n zone: string;\n prefix: string;\n pullHostname: string;\n publicBaseUrl: string;\n}\n\ntype Reader = (key: keyof typeof storageSettings) => string | number | undefined | Promise<string | number | undefined>;\n\n/** The module's settings from the product's accessor (`(key) => service.get(key, ctx)`); an unknown key falls back to the default. */\nexport async function readStorageSettings(read: Reader): Promise<StorageSettings> {\n const str = async (k: keyof typeof storageSettings) => {\n const v = await read(k);\n return typeof v === \"string\" ? v : \"\";\n };\n const provider = await read(\"storage.provider\");\n const maxMegabytes = await read(\"storage.maxMegabytes\");\n const signed = await read(\"storage.signedUrlSeconds\");\n return {\n provider: STORAGE_PROVIDERS.find((p) => p === provider) ?? STORAGE_DEFAULTS.provider,\n maxMegabytes: typeof maxMegabytes === \"number\" ? maxMegabytes : STORAGE_DEFAULTS.maxMegabytes,\n signedUrlSeconds: typeof signed === \"number\" ? signed : STORAGE_DEFAULTS.signedUrlSeconds,\n endpoint: await str(\"storage.endpoint\"),\n region: await str(\"storage.region\"),\n bucket: await str(\"storage.bucket\"),\n zone: await str(\"storage.zone\"),\n prefix: await str(\"storage.prefix\"),\n pullHostname: await str(\"storage.pullHostname\"),\n publicBaseUrl: await str(\"storage.publicBaseUrl\"),\n };\n}\n\nexport const limitsOf = (s: StorageSettings): StorageLimits => ({ maxBytes: s.maxMegabytes * MB, signedUrlSeconds: s.signedUrlSeconds });\n\n/** The credentials the product reads from its environment and passes in. */\nexport interface StorageSecrets {\n accessKeyId?: string;\n secretAccessKey?: string;\n /** The zone's password (`http`). */\n accessKey?: string;\n /** The pull zone's token-authentication key (`http`). */\n tokenKey?: string;\n /** The folder for `local`. */\n root?: string;\n}\n\n/**\n * The store the admin chose. A provider chosen without its coordinates or keys is refused loudly, never replaced by another: a file\n * must not land somewhere nobody expects. The `http` endpoint is the host only (`storage.bunnycdn.com`); an `s3` endpoint is checked\n * against our own network (`checkEndpoint`) unless `allowLocalEndpoint` (development).\n * @throws StorageError `STORAGE_BAD_CONFIG` or `STORAGE_BAD_ENDPOINT`\n */\nexport function storeFromSettings(s: StorageSettings, secrets: StorageSecrets, deps: { fetch: FetchLike; clock: Clock; allowLocalEndpoint?: boolean }): ObjectStore {\n const need = (value: string | undefined, what: string) => {\n if (!value) throw new StorageError(STORAGE_CODE.badConfig, `storage.provider is ${s.provider} but ${what} is not set`);\n return value;\n };\n const optional = <K extends string>(key: K, value: string): Partial<Record<K, string>> => (value === \"\" ? {} : ({ [key]: value } as Record<K, string>));\n if (s.provider === \"local\") return folderStore({ root: need(secrets.root, \"the folder\"), ...optional(\"publicBaseUrl\", s.publicBaseUrl) });\n if (s.provider === \"http\") {\n const endpoint = need(s.endpoint, \"storage.endpoint\");\n if (/[/:]/.test(endpoint)) throw new StorageError(STORAGE_CODE.badEndpoint, \"the http endpoint is a host name only\");\n return remoteStore({ endpoint, zone: need(s.zone, \"storage.zone\"), accessKey: need(secrets.accessKey, \"the zone password\"), ...optional(\"prefix\", s.prefix), ...optional(\"pullHostname\", s.pullHostname), ...optional(\"tokenKey\", secrets.tokenKey ?? \"\"), ...deps });\n }\n return bucketStore({\n endpoint: checkEndpoint(need(s.endpoint, \"storage.endpoint\"), deps.allowLocalEndpoint ? { allowLocal: true } : {}),\n region: need(s.region, \"storage.region\"),\n bucket: need(s.bucket, \"storage.bucket\"),\n accessKeyId: need(secrets.accessKeyId, \"the access key id\"),\n secretAccessKey: need(secrets.secretAccessKey, \"the secret access key\"),\n ...optional(\"prefix\", s.prefix),\n ...optional(\"publicBaseUrl\", s.publicBaseUrl),\n ...deps,\n });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { O as ObjectStore } from './port-CUTAx9CD.js';
|
|
2
|
+
|
|
3
|
+
/** Trims slashes at the ends without a regular expression (a long run of slashes must not be quadratic). */
|
|
4
|
+
declare function trimSlashes(value: string, ends?: "both" | "end"): string;
|
|
5
|
+
/**
|
|
6
|
+
* A key is `a/b/name.ext`: no empty segment, no `.` or `..`, no leading or trailing slash, no backslash, no control character.
|
|
7
|
+
* @throws StorageError `STORAGE_BAD_KEY`
|
|
8
|
+
*/
|
|
9
|
+
declare function assertKey(key: string): string;
|
|
10
|
+
/** The first 10 hex characters of the SHA-256: the rule of the Krizaka media library (`name.<hash>.ext`). */
|
|
11
|
+
declare const hashOf: (bytes: Uint8Array) => string;
|
|
12
|
+
/** `photo.jpg` and its bytes give `photo.<sha10>.jpg`: a URL never changes content, so it is cached for good. */
|
|
13
|
+
declare function hashedName(name: string, bytes: Uint8Array): string;
|
|
14
|
+
/**
|
|
15
|
+
* The prefixes a zone allows, as data: an unknown prefix is refused. A layout lists prefixes WITH a trailing slash (`site/home/`);
|
|
16
|
+
* a key belongs to the prefix it starts with.
|
|
17
|
+
*/
|
|
18
|
+
interface Layout {
|
|
19
|
+
prefixes: readonly string[];
|
|
20
|
+
/** The prefix a key belongs to, or `null`. */
|
|
21
|
+
prefixOf(key: string): string | null;
|
|
22
|
+
/** @throws StorageError `STORAGE_BAD_PREFIX` when the key is under no declared prefix. */
|
|
23
|
+
check(key: string): string;
|
|
24
|
+
}
|
|
25
|
+
declare function defineLayout(prefixes: readonly string[]): Layout;
|
|
26
|
+
/** The content type a file name suggests, for stores that do not keep one. Never a security decision: see `sniffType`. */
|
|
27
|
+
declare const typeOfName: (key: string) => string;
|
|
28
|
+
|
|
29
|
+
interface StorageLimits {
|
|
30
|
+
/** The largest file accepted, in bytes. */
|
|
31
|
+
maxBytes: number;
|
|
32
|
+
/** Seconds a signed link lives. */
|
|
33
|
+
signedUrlSeconds: number;
|
|
34
|
+
}
|
|
35
|
+
interface StorageDeps {
|
|
36
|
+
store: ObjectStore;
|
|
37
|
+
layout: Layout;
|
|
38
|
+
/** Read at each call so the admin's change applies at once. */
|
|
39
|
+
limits: StorageLimits | (() => StorageLimits | Promise<StorageLimits>);
|
|
40
|
+
}
|
|
41
|
+
interface UploadInput {
|
|
42
|
+
/** The layout prefix the file goes under (`site/home/`). */
|
|
43
|
+
prefix: string;
|
|
44
|
+
/** The name the file arrives with; the stored name is `<name>.<sha10>.<ext>`. */
|
|
45
|
+
name: string;
|
|
46
|
+
bytes: Uint8Array;
|
|
47
|
+
/** The content types this upload may be: the BYTES must be one of them, whatever the name or the declared type say. */
|
|
48
|
+
allow: readonly string[];
|
|
49
|
+
/** The type the client declared, when it did: it must agree with the bytes. */
|
|
50
|
+
declaredType?: string;
|
|
51
|
+
}
|
|
52
|
+
interface Storage {
|
|
53
|
+
/** Checks the size, the prefix and the bytes' magic number, then stores the file under a content-hashed name. Answers its key, and a link when one exists. */
|
|
54
|
+
upload(input: UploadInput): Promise<{
|
|
55
|
+
key: string;
|
|
56
|
+
contentType: string;
|
|
57
|
+
size: number;
|
|
58
|
+
url: string | null;
|
|
59
|
+
}>;
|
|
60
|
+
/** The public link when the zone has one, else a signed link, else `null`. */
|
|
61
|
+
urlFor(key: string): Promise<string | null>;
|
|
62
|
+
get: ObjectStore["get"];
|
|
63
|
+
head: ObjectStore["head"];
|
|
64
|
+
delete: ObjectStore["delete"];
|
|
65
|
+
list: ObjectStore["list"];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The storage of a product: an `ObjectStore` with the rules every upload obeys (a size limit passed in, a declared layout, bytes
|
|
69
|
+
* checked by magic number, a content-hashed name).
|
|
70
|
+
*/
|
|
71
|
+
declare function createStorage(deps: StorageDeps): Storage;
|
|
72
|
+
|
|
73
|
+
export { type Layout as L, type Storage as S, type UploadInput as U, type StorageDeps as a, type StorageLimits as b, assertKey as c, createStorage as d, defineLayout as e, hashedName as f, typeOfName as g, hashOf as h, trimSlashes as t };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { O as ObjectStore } from './port-CUTAx9CD.js';
|
|
2
|
+
|
|
3
|
+
/** What every `ObjectStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store. */
|
|
4
|
+
declare function objectStoreContract(name: string, make: () => ObjectStore | Promise<ObjectStore>): void;
|
|
5
|
+
|
|
6
|
+
export { objectStoreContract };
|