diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2ea37c2 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,65 @@ +# Nanoo CDN Architecture + +This document shows how the system works and how requests move through it, maybe in the future there will be improve + +## How the worker processes a request + +This diagram shows the steps the Worker takes for each new request. It also shows how the cache helps to make it faster. + +```mermaid +graph TD + A[User Request] --> B{Method GET/HEAD?} + B -- No --> C[Return 405 Method Not Allowed] + B -- Yes --> D[Generate cacheKey] + + D --> E{Cache HIT?} + E -- Yes --> F[Return Cached Response] + E -- No --> G[Clean the URL Path] + + G --> H{List Bucket Request?} + H -- Yes --> I[Return 404/Forbidden] + H -- No --> J[Sign Request for Security] + + J --> K{Range Header Present?} + K -- Yes --> L[Fetch from B2 with Retries] + K -- No --> M[Fetch from B2] + + L --> N{Response OK?} + M --> N + + N -- Yes --> O[Save to Edge Cache] + O --> P[Return File to User] + N -- No --> Q[Return Error Response] +``` + +## Step by step request flow + +This diagram shows how the User, Cloudflare, and backblaze B2 talk to each other. + +```mermaid +sequenceDiagram + participant U as User + participant E as Cloudflare Edge Cache + participant W as Worker (src/index.js) + participant B as Backblaze B2 (Origin) + + U->>E: GET /assets/logo.png + alt Cache HIT + E-->>U: Return file (very fast) + else Cache MISS + E->>W: Forward Request + W->>W: Clean Path & Headers + W->>W: Sign Request for Security + W->>B: Send Request to B2 + B-->>W: 200 OK / 206 Partial Content + W->>E: Save file to Cache + W-->>U: Return File + end +``` + +## Project Parts + +- **`src/index.js`**: **Main controller** +- **`src/lib/signer.js`**: **Security handler** +- **`src/lib/cache.js`**: **Speed manager** +- **`src/lib/utils.js`**: **Helper tools** diff --git a/CHANGELOG.md b/CHANGELOG.md index 977a29c..a33f168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,19 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.0.0] - 2026-05-18 -### Fixed - -- Updated README to use current `npm create cloudflare` and `npx wrangler deploy` commands ([@harrisonratcliffe](https://github.com/harrisonratcliffe/)) -- Fixed comments around RCLONE_DOWNLOAD in wrangler.toml.template ([@jingyuanliang](https://github.com/jingyuanliang/)) -- Do not sign conditional request headers, since they are not always passed upstream by Cloudflare. -- Fixed `RCLONE_DOWNLOAD` option so that bucket name can be passed in the path. +### Added +- Integration Cloudflare cache API for 300x faster TTFB (3ms-9ms on HIT) +- Refactore codebase to `src/` and `src/lib/` for better maintain +- Add `ARCHITECTURE.md` with Mermaid diagram +- add Log prefix `[CACHE]`, `[SIGN]`, and `[B2]` ### Changed +- Move main entry point to `src/index.js` +- Enhance header filtering and path sanitization logic + +### Fixed +- Resolve `TypeError` when modification headers for cache API +- Update README to use current `pnpm create cloudflare` and `pnpm dlx wrangler deploy` command +- Fix `RCLONE_DOWNLOAD` option so that bucket name can be passed in the path -- Bumped direct dependencies to current versions, moved `wrangler` to `devDependencies`. -- Removed user-agent check for rcloneDownload - this allows other clients to use B2 friendly URLs. ## [1.2.0] - 2024-10-09 diff --git a/README.md b/README.md index 1f7bb41..3a56a46 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,21 @@ -# Nanoo CDN +# Nanoo CDN v2.0.0 -A high-performance CDN edge proxy for nanoo ecosystems it uses Cloudflare Workers to serve files securely from a private Backblaze B2 bucket. +High performance, secure edge proxy using cloudflare workers and backblaze B2. +zero egress costs, 3ms-9ms TTFB, and AWS SigV4 security -This project is based on the cloudflare-b2 implementation. +## Quick start -## Architecture +1. **Install:** `pnpm install` +2. **Setup:** Create `.dev.vars` from `.dev.vars.templates` and add your `B2_APPLICATION_KEY` +3. **Dev:** `pnpm dlx wrangler dev` (testing at `http://localhost:8787`) +4. **Deploy:** `pnpm dlx wrangler deploy` -This service works as a bridge between users and private storage. +## Documentation +- **[Architecture & flow](ARCHITECTURE.md)**: Deep dive to the request lifecycle and mermaid diagrams +- **[Changelog](CHANGELOG.md)**: Track the latest updates and v2.0 improvements -Client -> Cloudflare Worker -> Backblaze B2 - -The B2 bucket is private. The Worker signs each request using AWS Signature Version 4 and fetches the file from the B2 endpoint. This prevents unauthorized access to your files. - -### Benefits -- No Egress Cost: Moving data from Backblaze B2 to Cloudflare is free because of the Bandwidth Alliance. -- Edge Caching: Files are cached at Cloudflare edge nodes. This makes delivery faster and reduces costs. -- Security: Directory listing is disabled. The Worker uses a restricted key that can only read from the nanoo-assets bucket. - - - -## Getting Started - -### Prerequisites -- pnpm installed. -- A Cloudflare account with the domain nanoolabs.dev -- A Backblaze B2 account with a private bucket. - -### Local Development -1. Install dependencies: - ```bash - pnpm install - ``` -2. Create a .dev.vars file from the .dev.vars.template and add your B2_APPLICATION_KEY. -3. Start the development server: - ```bash - pnpm dlx wrangler dev - ``` - -### Deployment -Deployment is done with Wrangler. You must save your secrets in Cloudflare: - -```bash -# Save your B2 Secret Key (run this once) -pnpm dlx wrangler secret put B2_APPLICATION_KEY -``` - -# Deploy to production -```bash -pnpm dlx wrangler deploy -``` - -## Credits - -This project uses the following resources: -- [cloudflare-b2](https://github.com/backblaze-b2-samples/cloudflare-b2) by Backblaze for the original implementation. -- Cloudflare and Backblaze for providing the bandwidth alliance. +## Credit +Based on the [cloudflare-b2](https://github.com/backblaze-b2-samples/cloudflare-b2) implementation by backblaze ## License -This project is licensed under the MIT License and Apache License 2.0. \ No newline at end of file +Licensed under MIT and Apache 2.0 diff --git a/cors.json b/cors.json new file mode 100644 index 0000000..6ae1257 --- /dev/null +++ b/cors.json @@ -0,0 +1,17 @@ +[ + { + "corsRuleName": "allow-nanoo-labs", + "allowedOrigins": [ + "https://nanoo.biz.id", + "https://api.nanoo.biz.id", + "https://me.nanoolabs.dev", + "https://docs.nanoolabs.dev", + "https://nanoolabs.dev", + "https://me.nanoolabs.dev" + ], + "allowedHeaders": ["*"], + "allowedOperations": ["s3_get", "s3_head"], + "exposeHeaders": ["ETag", "Content-Type", "Content-Length"], + "maxAgeSeconds": 3600 + } +] diff --git a/cors.json.template b/cors.json.template new file mode 100644 index 0000000..8345641 --- /dev/null +++ b/cors.json.template @@ -0,0 +1,13 @@ +[ + { + "corsRuleName": "allow-your-domain", + "allowedOrigins": [ + "https://your-domain.com", + "https://api.your-domain.com" + ], + "allowedHeaders": ["*"], + "allowedOperations": ["s3_get", "s3_head"], + "exposeHeaders": ["ETag", "Content-Type", "Content-Length"], + "maxAgeSeconds": 3600 + } +] diff --git a/index.js b/index.js deleted file mode 100644 index 0f4c678..0000000 --- a/index.js +++ /dev/null @@ -1,223 +0,0 @@ -// -// Proxy Backblaze S3 compatible API requests, sending notifications to a webhook -// -// Adapted from https://github.com/obezuk/worker-signed-s3-template -// -import { AwsClient } from 'aws4fetch' - -const UNSIGNABLE_HEADERS = [ - // These headers appear in the request, but are never passed upstream - 'x-forwarded-proto', - 'x-real-ip', - // We can't include accept-encoding in the signature because Cloudflare - // sets the incoming accept-encoding header to "gzip, br", then modifies - // the outgoing request to set accept-encoding to "gzip". - // Not cool, Cloudflare! - 'accept-encoding', - // Conditional headers are not consistently passed upstream - 'if-match', - 'if-modified-since', - 'if-none-match', - 'if-range', - 'if-unmodified-since', -] - -// URL needs colon suffix on protocol, and port as a string -const HTTPS_PROTOCOL = 'https:' -const HTTPS_PORT = '443' - -// How many times to retry a range request where the response is missing content-range -const RANGE_RETRY_ATTEMPTS = 3 - -// Filter out cf-* and any other headers we don't want to include in the signature -function filterHeaders(headers, env) { - // Suppress irrelevant IntelliJ warning - // noinspection JSCheckFunctionSignatures - return new Headers( - Array.from(headers.entries()).filter( - (pair) => - !( - UNSIGNABLE_HEADERS.includes(pair[0]) || - pair[0].startsWith('cf-') || - ('ALLOWED_HEADERS' in env && - !env['ALLOWED_HEADERS'].includes(pair[0])) - ), - ), - ) -} - -function createHeadResponse(response) { - return new Response(null, { - headers: response.headers, - status: response.status, - statusText: response.statusText, - }) -} - -function isListBucketRequest(env, path) { - const pathSegments = path.split('/') - - return ( - (env['BUCKET_NAME'] === '$path' && pathSegments.length < 2) || // https://endpoint/bucket-name/ - (env['BUCKET_NAME'] !== '$path' && path.length === 0) - ) // https://bucket-name.endpoint/ or https://endpoint/ -} - -// Supress IntelliJ's "unused default export" warning -// noinspection JSUnusedGlobalSymbols -export default { - async fetch(request, env) { - // Only allow GET and HEAD methods - if (!['GET', 'HEAD'].includes(request.method)) { - return new Response(null, { - status: 405, - statusText: 'Method Not Allowed', - }) - } - - const url = new URL(request.url) - - // Incoming protocol and port is taken from the worker's environment. - // Local dev mode uses plain http on 8787, and it's possible to deploy - // a worker on plain http. B2 only supports https on 443 - url.protocol = HTTPS_PROTOCOL - url.port = HTTPS_PORT - - // Remove leading slashes from path - let path = url.pathname.replace(/^\//, '') - // Remove trailing slashes - path = path.replace(/\/$/, '') - - // Reject list bucket requests unless configuration allows it - if ( - isListBucketRequest(env, path) && - String(env['ALLOW_LIST_BUCKET']) !== 'true' - ) { - return new Response(null, { - status: 404, - statusText: 'Not Found', - }) - } - - // Set RCLONE_DOWNLOAD to "true" to use rclone with --b2-download-url - // See https://rclone.org/b2/#b2-download-url - const rcloneDownload = String(env['RCLONE_DOWNLOAD']) === 'true' - - // Set upstream target hostname. - switch (env['BUCKET_NAME']) { - case '$path': - // Bucket name is initial segment of URL path - url.hostname = env['B2_ENDPOINT'] - break - case '$host': - // Bucket name is initial subdomain of the incoming hostname - url.hostname = url.hostname.split('.')[0] + '.' + env['B2_ENDPOINT'] - break - default: - // Bucket name is specified in the BUCKET_NAME variable - url.hostname = env['BUCKET_NAME'] + '.' + env['B2_ENDPOINT'] - break - } - - // Certain headers, such as x-real-ip, appear in the incoming request but - // are removed from the outgoing request. If they are in the outgoing - // signed headers, B2 can't validate the signature. - const headers = filterHeaders(request.headers, env) - - // Create an S3 API client that can sign the outgoing request - const client = new AwsClient({ - accessKeyId: env['B2_APPLICATION_KEY_ID'], - secretAccessKey: env['B2_APPLICATION_KEY'], - service: 's3', - }) - - // Save the request method, so we can process responses for HEAD requests appropriately - const requestMethod = request.method - - if (rcloneDownload) { - if (env['BUCKET_NAME'] === '$path') { - // Remove leading file/ prefix from the path - url.pathname = path.replace(/^file\//, '') - } else { - // Remove leading file/{bucket_name}/ prefix from the path - url.pathname = path.replace(/^file\/[^/]+\//, '') - } - } - - // Sign the outgoing request - // - // For HEAD requests Cloudflare appears to change the method on the outgoing request to GET (#18), which - // breaks the signature, resulting in a 403. So, change all HEADs to GETs. This is not too inefficient, - // since we won't read the body of the response if the original request was a HEAD. - const signedRequest = await client.sign(url.toString(), { - method: 'GET', - headers: headers, - }) - - // For large files, Cloudflare will return the entire file, rather than the requested range - // So, if there is a range header in the request, check that the response contains the - // content-range header. If not, abort the request and try again. - // See https://community.cloudflare.com/t/cloudflare-worker-fetch-ignores-byte-request-range-on-initial-request/395047/4 - if (signedRequest.headers.has('range')) { - let attempts = RANGE_RETRY_ATTEMPTS - let response - do { - let controller = new AbortController() - response = await fetch(signedRequest.url, { - method: signedRequest.method, - headers: signedRequest.headers, - signal: controller.signal, - }) - if (response.headers.has('content-range')) { - // Only log if it didn't work first time - if (attempts < RANGE_RETRY_ATTEMPTS) { - console.log( - `Retry for ${signedRequest.url} succeeded - response has content-range header`, - ) - } - // Break out of loop and return the response - break - } else if (response.ok) { - attempts -= 1 - console.error( - `Range header in request for ${signedRequest.url} but no content-range header in response. Will retry ${attempts} more times`, - ) - // Do not abort on the last attempt, as we want to return the response - if (attempts > 0) { - controller.abort() - } - } else { - // Response is not ok, so don't retry - break - } - } while (attempts > 0) - - if (attempts <= 0) { - console.error( - `Tried range request for ${signedRequest.url} ${RANGE_RETRY_ATTEMPTS} times, but no content-range in response.`, - ) - } - - if (requestMethod === 'HEAD') { - // Original request was HEAD, so return a new Response without a body - return createHeadResponse(response) - } - - // Return whatever response we have rather than an error response - // This response cannot be aborted, otherwise it will raise an exception - return response - } - - // Send the signed request to B2 - const fetchPromise = fetch(signedRequest) - - if (requestMethod === 'HEAD') { - const response = await fetchPromise - // Original request was HEAD, so return a new Response without a body - return createHeadResponse(response) - } - - // Return the upstream response unchanged - return fetchPromise - }, -} diff --git a/package.json b/package.json index 7266316..f91e734 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "cloudflare-b2", - "version": "1.2.0", + "version": "2.0.0", "description": "Provide access to a private Backblaze B2 bucket via a Cloudflare Worker", "main": "index.js", "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..de87b63 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,888 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + aws4fetch: + specifier: ^1.0.20 + version: 1.0.20 + devDependencies: + prettier: + specifier: ^3.7.4 + version: 3.8.3 + wrangler: + specifier: ^4.90.1 + version: 4.92.0 + +packages: + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260515.1': + resolution: {integrity: sha512-Wtw44el2pNbzixvTkWdfeBDTrQwQbJRz7/JUvPKV27I0pQWXbhNJPpM8cstq/pbrU5AGcA/HjFH6yPMRTIRKig==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260515.1': + resolution: {integrity: sha512-X8EqkZej6FfmhF9AVAQ3FhyQRr9acS4RcDunMU2YiuxKHF1IU8zzH3vY30/POaG+rUu9vGDp/VgUl49VPenHJQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260515.1': + resolution: {integrity: sha512-CDC89QxQ7Y7t7RG1Jd9vj/qolE1sQRkI2OSEuV5BMJi0vW/gV4OVG6xjpdK3b1OYnSWDzF7NpvlR5Yg86q7k4g==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260515.1': + resolution: {integrity: sha512-WxbW/PToYES4fvHXzsr/5qOiETQs/Z9iZ0mjSZAiEwq5cMLZemzGN0COx+uFb9OvQwzh6Pg159qPFnw3+i9FuA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260515.1': + resolution: {integrity: sha512-WmV/iv+MHjYsvkcMVzpM2B5/mf06UUkdpVhZrtMfV9graWjBGPYFvE/eab8748RPVGKh1Xe1vXofLzDSwc08lA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.15': + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + miniflare@4.20260515.0: + resolution: {integrity: sha512-2j0oQWizk1Eu4Cm8tDX7Z+Nsjd0nebIj1TQcQ+Oy1QKeo0Ay9+bdn8wfLAtOj9znDCybDCUlnS1+nYvKXEdfNg==} + engines: {node: '>=22.0.0'} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260515.1: + resolution: {integrity: sha512-MjKOJLcvU45xXedQowvuiHtJTxu4WTHYQeIlF7YmjuqhiI6dImTFxWCEoRQHiskztxuVSNEmdO7/0UfDu6OMnQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.92.0: + resolution: {integrity: sha512-/DKpQHPxkuZbQsO9dFW2700VTD/4DSZMHjy92fO/frNoDRi/zQsFCAd2ONCV6TGqcUoXcP3D8Bo2gj/L4M0qQQ==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260515.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260515.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260515.1 + + '@cloudflare/workerd-darwin-64@1.20260515.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260515.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260515.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260515.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260515.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.15': {} + + aws4fetch@1.0.20: {} + + blake3-wasm@2.1.5: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + fsevents@2.3.3: + optional: true + + kleur@4.1.5: {} + + miniflare@4.20260515.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260515.1 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + prettier@3.8.3: {} + + semver@7.8.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.0 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + + undici@7.24.8: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260515.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260515.1 + '@cloudflare/workerd-darwin-arm64': 1.20260515.1 + '@cloudflare/workerd-linux-64': 1.20260515.1 + '@cloudflare/workerd-linux-arm64': 1.20260515.1 + '@cloudflare/workerd-windows-64': 1.20260515.1 + + wrangler@4.92.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260515.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260515.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260515.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.15 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..21e8cb1 --- /dev/null +++ b/src/index.js @@ -0,0 +1,144 @@ +// +// Proxy Backblaze S3 compatible API requests, sending notifications to a webhook +// +// Adapted from https://github.com/obezuk/worker-signed-s3-template +// +import { + sanitizePath, + isListBucketRequest, + createHeadResponse, +} from './lib/utils.js' +import { signRequest, getUpstreamHostname } from './lib/signer.js' +import { getCacheResponse, saveToCache } from './lib/cache.js' + +// How many times to retry a range request where the response is missing content-range +const RANGE_RETRY_ATTEMPTS = 3 + +// Supress IntelliJ's "unused default export" warning +// noinspection JSUnusedGlobalSymbols +export default { + async fetch(request, env, ctx) { + // Only allow GET and HEAD methods + if (!['GET', 'HEAD'].includes(request.method)) { + return new Response(null, { + status: 405, + statusText: 'Method Not Allowed', + }) + } + + // Edge Caching: Check if we have a HIT before doing any heavy lifting. + const cachedResponse = await getCacheResponse(request) + if (cachedResponse) { + console.log('[CACHE] HIT:', new URL(request.url).pathname) + // Original request was HEAD, so return a new Response without a body + return request.method === 'HEAD' + ? createHeadResponse(cachedResponse) + : cachedResponse + } + console.log('[CACHE] MISS:', new URL(request.url).pathname) + + const url = new URL(request.url) + const path = sanitizePath(url.pathname) + + // Reject list bucket requests unless configuration allows it + if ( + isListBucketRequest(env, path) && + String(env['ALLOW_LIST_BUCKET']) !== 'true' + ) { + return new Response(null, { + status: 404, + statusText: 'Not Found', + }) + } + + // Set upstream target hostname. + url.hostname = getUpstreamHostname(env, url.hostname) + + // Set RCLONE_DOWNLOAD to "true" to use rclone with --b2-download-url + // See https://rclone.org/b2/#b2-download-url + const rcloneDownload = String(env['RCLONE_DOWNLOAD']) === 'true' + if (rcloneDownload) { + if (env['BUCKET_NAME'] === '$path') { + // Remove leading file/ prefix from the path + url.pathname = path.replace(/^file\//, '') + } else { + // Remove leading file/{bucket_name}/ prefix from the path + url.pathname = path.replace(/^file\/[^/]+\//, '') + } + } + + // Sign the outgoing request + console.log('[SIGN] Requesting:', url.toString()) + const signedRequest = await signRequest(request, env, url) + console.log('[SIGN] Success') + + // Save the request method, so we can process responses for HEAD requests appropriately + const requestMethod = request.method + + // For large files, Cloudflare will return the entire file, rather than the requested range + // So, if there is a range header in the request, check that the response contains the + // content-range header. If not, abort the request and try again. + // See https://community.cloudflare.com/t/cloudflare-worker-fetch-ignores-byte-request-range-on-initial-request/395047/4 + let response + if (signedRequest.headers.has('range')) { + console.log('[B2] Fetching range request...') + let attempts = RANGE_RETRY_ATTEMPTS + do { + const controller = new AbortController() + response = await fetch(signedRequest.url, { + method: signedRequest.method, + headers: signedRequest.headers, + signal: controller.signal, + }) + console.log('[B2] Range response status:', response.status) + if (response.headers.has('content-range')) { + // Only log if it didn't work first time + if (attempts < RANGE_RETRY_ATTEMPTS) { + console.log( + `[B2] Retry for ${signedRequest.url} succeeded - response has content-range header`, + ) + } + break + } else if (response.ok) { + attempts -= 1 + console.error( + `[B2] Range header in request for ${signedRequest.url} but no content-range header in response. Will retry ${attempts} more times`, + ) + // Do not abort on the last attempt, as we want to return the response + if (attempts > 0) { + controller.abort() + } + } else { + // Response is not ok, so don't retry + break + } + } while (attempts > 0) + + if (attempts <= 0) { + console.error( + `[B2] Tried range request for ${signedRequest.url} ${RANGE_RETRY_ATTEMPTS} times, but no content-range in response.`, + ) + } + } else { + console.log('[B2] Fetching full request...') + // Send the signed request to B2 + response = await fetch(signedRequest) + console.log('[B2] Full response status:', response.status) + } + + // Cache the response if it's successful (200 or 206) + if (response.ok) { + console.log('[CACHE] Saving...') + await saveToCache(request, response, ctx) + console.log('[CACHE] Success') + } + + if (requestMethod === 'HEAD') { + // Original request was HEAD, so return a new Response without a body + return createHeadResponse(response) + } + + // Return whatever response we have rather than an error response + return response + }, +} diff --git a/src/lib/cache.js b/src/lib/cache.js new file mode 100644 index 0000000..1ec0915 --- /dev/null +++ b/src/lib/cache.js @@ -0,0 +1,33 @@ +export async function getCacheResponse(request) { + // Construct a cache key that is stable but accounts for range requests + // We use the original URL and the Range header as the key. + const cacheKey = new Request(request.url, { + headers: request.headers, + method: 'GET', // Always cache as GET even if request is HEAD + }) + return await caches.default.match(cacheKey) +} + +export async function saveToCache(request, response, ctx) { + // Cache the response if it's successful (200 or 206) + if (!response.ok) return + + const cacheKey = new Request(request.url, { + headers: request.headers, + method: 'GET', + }) + + // We clone the response to store it in cache without consuming the original body + // IMPORTANT: We must create a new Response object to modify headers. + // Responses from fetch() have immutable headers. Simply cloning doesn't work. + const cacheResponse = response.clone() + const mutableResponse = new Response(cacheResponse.body, cacheResponse) + + // We add a Cache-Control header if B2 doesn't provide one, or to override it + // s-maxage=3600 tells Cloudflare to cache it for 1 hour at the edge + mutableResponse.headers.set('Cache-Control', 'public, s-maxage=3600') + + // Use ctx.waitUntil so the response is sent to the user immediately, + // while the cache write happens in the background. + ctx.waitUntil(caches.default.put(cacheKey, mutableResponse)) +} diff --git a/src/lib/signer.js b/src/lib/signer.js new file mode 100644 index 0000000..59050c6 --- /dev/null +++ b/src/lib/signer.js @@ -0,0 +1,51 @@ +import { AwsClient } from 'aws4fetch' +import { filterHeaders } from './utils.js' + +// URL needs colon suffix on protocol, and port as a string +const HTTPS_PROTOCOL = 'https:' +const HTTPS_PORT = '443' + +export async function signRequest(request, env, url) { + // Incoming protocol and port is taken from the worker's environment. + // Local dev mode uses plain http on 8787, and it's possible to deploy + // a worker on plain http. B2 only supports https on 443 + url.protocol = HTTPS_PROTOCOL + url.port = HTTPS_PORT + + // Certain headers, such as x-real-ip, appear in the incoming request but + // are removed from the outgoing request. If they are in the outgoing + // signed headers, B2 can't validate the signature. + const headers = filterHeaders(request.headers, env) + + // Create an S3 API client that can sign the outgoing request + const client = new AwsClient({ + accessKeyId: env['B2_APPLICATION_KEY_ID'], + secretAccessKey: env['B2_APPLICATION_KEY'], + service: 's3', + }) + + // Sign the outgoing request + // + // For HEAD requests Cloudflare appears to change the method on the outgoing request to GET (#18), which + // breaks the signature, resulting in a 403. So, change all HEADs to GETs. This is not too inefficient, + // since we won't read the body of the response if the original request was a HEAD. + return await client.sign(url.toString(), { + method: 'GET', + headers: headers, + }) +} + +export function getUpstreamHostname(env, incomingHostname) { + // Set upstream target hostname. + switch (env['BUCKET_NAME']) { + case '$path': + // Bucket name is initial segment of URL path + return env['B2_ENDPOINT'] + case '$host': + // Bucket name is initial subdomain of the incoming hostname + return incomingHostname.split('.')[0] + '.' + env['B2_ENDPOINT'] + default: + // Bucket name is specified in the BUCKET_NAME variable + return env['BUCKET_NAME'] + '.' + env['B2_ENDPOINT'] + } +} diff --git a/src/lib/utils.js b/src/lib/utils.js new file mode 100644 index 0000000..5082a86 --- /dev/null +++ b/src/lib/utils.js @@ -0,0 +1,58 @@ +const UNSIGNABLE_HEADERS = [ + // These headers appear in the request, but are never passed upstream + 'x-forwarded-proto', + 'x-real-ip', + // We can't include accept-encoding in the signature because Cloudflare + // sets the incoming accept-encoding header to "gzip, br", then modifies + // the outgoing request to set accept-encoding to "gzip". + // Not cool, Cloudflare! + 'accept-encoding', + // Conditional headers are not consistently passed upstream + 'if-match', + 'if-modified-since', + 'if-none-match', + 'if-range', + 'if-unmodified-since', +] + +// Filter out cf-* and any other headers we don't want to include in the signature +export function filterHeaders(headers, env) { + // Suppress irrelevant IntelliJ warning + // noinspection JSCheckFunctionSignatures + return new Headers( + Array.from(headers.entries()).filter( + (pair) => + !( + UNSIGNABLE_HEADERS.includes(pair[0]) || + pair[0].startsWith('cf-') || + ('ALLOWED_HEADERS' in env && + !env['ALLOWED_HEADERS'].includes(pair[0])) + ), + ), + ) +} + +export function createHeadResponse(response) { + return new Response(null, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }) +} + +export function isListBucketRequest(env, path) { + const pathSegments = path.split('/') + + return ( + (env['BUCKET_NAME'] === '$path' && pathSegments.length < 2) || // https://endpoint/bucket-name/ + (env['BUCKET_NAME'] !== '$path' && path.length === 0) + ) // https://bucket-name.endpoint/ or https://endpoint/ +} + +export function sanitizePath(pathname) { + // Remove leading slashes from path + let path = pathname.replace(/^\/+/, '') + // Remove trailing slashes + path = path.replace(/\/+$/, '') + return path +} diff --git a/wrangler.toml b/wrangler.toml index 7886df2..38326af 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -4,12 +4,12 @@ name = "nanoocdn" workers_dev = true compatibility_date = "2023-09-04" -main = "index.js" +main = "src/index.js" upload_source_maps = true compatibility_flags = [ "nodejs_compat" ] [vars] -B2_APPLICATION_KEY_ID = "0038f4a4d378b380000000003" +B2_APPLICATION_KEY_ID = "0038f4a4d378b380000000005" B2_ENDPOINT = "s3.eu-central-003.backblazeb2.com" BUCKET_NAME = "nanoo-assets" ALLOW_LIST_BUCKET = "false"