From fa554edadaeeb7c6b212edaa4d240e1117404c6f Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 6 Nov 2026 10:33:48 +0900 Subject: [PATCH 1/6] feat(s3): add CORS, web console, and full SigV4 checks to standalone image CORS: PutBucketCors/GetBucketCors/DeleteBucketCors, preflight, and response headers, plus DEVCLOUD_S3_CORS_ALLOW_ORIGINS as a default for buckets without their own rules. Nothing is allowed unless configured. Web console: embedded React app and JSON API on DEVCLOUD_S3_CONSOLE_ADDR (9001 in the image). Mutations go through the S3 provider protocol. Strict mode adds HTTP Basic; a Host allowlist stops DNS rebinding. SigV4 in strict mode: verify aws-chunked chunk and trailer signatures, enforce a 15-minute clock skew and credential scope date, require host and behavioural x-amz-* headers to be signed, and compare presigned signatures in constant time. The binary refuses an unknown auth mode or strict mode without credentials. Fixes: - Keys with spaces, non-ASCII, or reserved characters failed strict auth because the canonical URI was double-encoded. - aws-chunked framing was stored as part of the object in relaxed mode. - A non-ASCII x-amz-date panicked while holding the store lock. - Transfer-Encoding: chunked bodies were read as empty; conflicting Content-Length values are now refused. --- Cargo.lock | 1 + README.md | 7 +- services/s3/Cargo.toml | 1 + services/s3/Dockerfile | 15 +- services/s3/README.md | 91 + .../assets/console/assets/index-Bxmjs9G2.css | 1 + .../assets/console/assets/index-CwBD1Nc8.js | 40 + services/s3/assets/console/index.html | 13 + services/s3/src/aws_chunked.rs | 110 + services/s3/src/console.rs | 350 +++ services/s3/src/cors.rs | 248 +++ services/s3/src/http.rs | 740 ++++++- services/s3/src/lib.rs | 3 + services/s3/src/main.rs | 97 +- services/s3/src/store.rs | 1 + services/s3/src/store_config.rs | 32 + services/s3/tests/console.rs | 336 +++ services/s3/tests/cors.rs | 365 ++++ services/s3/tests/http_parity.rs | 21 +- services/s3/tests/sigv4.rs | 623 ++++++ web/s3-console/index.html | 12 + web/s3-console/package-lock.json | 1882 +++++++++++++++++ web/s3-console/package.json | 23 + web/s3-console/src/App.tsx | 287 +++ web/s3-console/src/api.ts | 67 + web/s3-console/src/format.ts | 40 + web/s3-console/src/main.tsx | 9 + web/s3-console/src/styles/globals.css | 135 ++ web/s3-console/src/styles/tokens.css | 63 + web/s3-console/tsconfig.app.json | 22 + web/s3-console/tsconfig.json | 7 + web/s3-console/tsconfig.node.json | 16 + web/s3-console/vite.config.ts | 18 + 33 files changed, 5600 insertions(+), 76 deletions(-) create mode 100644 services/s3/README.md create mode 100644 services/s3/assets/console/assets/index-Bxmjs9G2.css create mode 100644 services/s3/assets/console/assets/index-CwBD1Nc8.js create mode 100644 services/s3/assets/console/index.html create mode 100644 services/s3/src/aws_chunked.rs create mode 100644 services/s3/src/console.rs create mode 100644 services/s3/src/cors.rs create mode 100644 services/s3/tests/console.rs create mode 100644 services/s3/tests/cors.rs create mode 100644 services/s3/tests/sigv4.rs create mode 100644 web/s3-console/index.html create mode 100644 web/s3-console/package-lock.json create mode 100644 web/s3-console/package.json create mode 100644 web/s3-console/src/App.tsx create mode 100644 web/s3-console/src/api.ts create mode 100644 web/s3-console/src/format.ts create mode 100644 web/s3-console/src/main.tsx create mode 100644 web/s3-console/src/styles/globals.css create mode 100644 web/s3-console/src/styles/tokens.css create mode 100644 web/s3-console/tsconfig.app.json create mode 100644 web/s3-console/tsconfig.json create mode 100644 web/s3-console/tsconfig.node.json create mode 100644 web/s3-console/vite.config.ts diff --git a/Cargo.lock b/Cargo.lock index 300e2db..eabcbda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,7 @@ version = "0.1.0" dependencies = [ "hex", "hmac", + "include_dir", "md-5", "serde", "serde_json", diff --git a/README.md b/README.md index 88fded1..8359658 100644 --- a/README.md +++ b/README.md @@ -295,8 +295,9 @@ Legend: | CopyObject | Yes | Supports copy and metadata replacement. | | Content-MD5 validation | Yes | Invalid and mismatched digests return S3-style errors. | | Multipart upload | Yes | Create/upload/list/complete/abort local multipart flows. | -| Presigned URL validation | Yes | Covered for local SigV4 GET. | -| AWS SigV4 header auth | Partial | Relaxed mode is default; strict mode validates local credentials. | +| Presigned URL validation | Yes | Strict mode validates SigV4 presigned GET and PUT, including expiry. | +| AWS SigV4 header auth | Partial | Relaxed mode is default; strict mode validates local credentials, a 15-minute clock skew window, and `aws-chunked` chunk and trailer signatures. SigV2 and SigV4a are not implemented. | +| CORS | Yes | PutBucketCors/GetBucketCors/DeleteBucketCors, preflight, and response headers. | | ACLs, bucket policy, IAM | Partial | Bucket policy and bucket/object ACL metadata endpoints are supported locally, including versionId-aware object ACL metadata; IAM enforcement is not implemented. | | Versioning | Partial | Bucket versioning, generated and `null` version IDs, version-aware get/delete/copy-source, delete markers, multipart-complete version IDs/ETags, and local ListObjectVersions with key/version markers are supported. | | Lifecycle | Partial | Bucket lifecycle metadata endpoints are supported; Enabled expiration rules for current objects are applied locally on S3 reads/lists. | @@ -307,6 +308,8 @@ Legend: | S3 Select | Partial | Minimal SelectObjectContent supports `SELECT * FROM S3Object` for CSV and JSON Lines with eventstream responses; filtering and projections are not implemented. | | Inventory / analytics | Partial | Bucket inventory and analytics configuration metadata endpoints are supported locally; CSV-format enabled inventory configs generate deterministic local reports under bucket storage. | +The standalone S3 image also ships a web console and a server-wide CORS default; see [`services/s3/README.md`](services/s3/README.md). + ### GCS-Compatible JSON API | Feature | Status | Notes | diff --git a/services/s3/Cargo.toml b/services/s3/Cargo.toml index 988d4b3..1949c6c 100644 --- a/services/s3/Cargo.toml +++ b/services/s3/Cargo.toml @@ -21,4 +21,5 @@ md-5 = "0.10" sha2 = "0.10" hmac = "0.12" hex = "0.4" +include_dir = "0.7" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] } diff --git a/services/s3/Dockerfile b/services/s3/Dockerfile index c613202..57b4773 100644 --- a/services/s3/Dockerfile +++ b/services/s3/Dockerfile @@ -5,11 +5,15 @@ # Multi-arch (the build stage runs natively and cross-compiles, because rustc # crashes under amd64 emulation on arm64 hosts): # docker buildx build --platform linux/amd64,linux/arm64 -f services/s3/Dockerfile . -# Run: -# docker run --rm -p 9000:9000 -v devcloud-s3-data:/data devcloud-s3 +# Run (S3 API on 9000, web console on 9001; see services/s3/README.md): +# docker run --rm -p 127.0.0.1:9000:9000 -p 127.0.0.1:9001:9001 \ +# -v devcloud-s3-data:/data devcloud-s3 # -# Auth defaults to relaxed. Strict auth: pass DEVCLOUD_S3_AUTH_MODE=strict together with -# DEVCLOUD_S3_ACCESS_KEY_ID / DEVCLOUD_S3_SECRET_ACCESS_KEY / DEVCLOUD_S3_REGION. +# Auth defaults to relaxed, which also leaves the console open. Signature +# verification: pass DEVCLOUD_S3_AUTH_MODE=strict together with +# DEVCLOUD_S3_ACCESS_KEY_ID / DEVCLOUD_S3_SECRET_ACCESS_KEY (and optionally +# DEVCLOUD_S3_REGION). CORS: PutBucketCors per bucket, or +# DEVCLOUD_S3_CORS_ALLOW_ORIGINS as a default for every bucket. FROM --platform=$BUILDPLATFORM rust:1-slim-bookworm AS build ARG TARGETARCH @@ -40,7 +44,8 @@ RUN useradd --system --uid 10001 --no-create-home devcloud \ COPY --from=build /devcloud-s3 /usr/local/bin/devcloud-s3 USER devcloud ENV DEVCLOUD_S3_ADDR=0.0.0.0:9000 \ + DEVCLOUD_S3_CONSOLE_ADDR=0.0.0.0:9001 \ DEVCLOUD_S3_STORAGE=/data VOLUME /data -EXPOSE 9000 +EXPOSE 9000 9001 ENTRYPOINT ["devcloud-s3"] diff --git a/services/s3/README.md b/services/s3/README.md new file mode 100644 index 0000000..ef14dc5 --- /dev/null +++ b/services/s3/README.md @@ -0,0 +1,91 @@ +# devcloud-s3 + +Standalone S3-compatible endpoint with a web console. The image runs without the +orchestrator, dashboard, or event relay, and needs no runtime JavaScript tools. + +```sh +docker build -f services/s3/Dockerfile -t devcloud-s3 . +docker run --rm \ + -p 127.0.0.1:9000:9000 -p 127.0.0.1:9001:9001 \ + -v devcloud-s3-data:/data devcloud-s3 +# S3 API: http://127.0.0.1:9000 Web console: http://127.0.0.1:9001 +``` + +The image runs as uid **10001**. For a bind mount, make the directory writable by +that uid first. Requests are path-style only and capped at 128 MiB. + +| Environment variable | Image default | Meaning | +| --- | --- | --- | +| `DEVCLOUD_S3_ADDR` | `0.0.0.0:9000` | S3 API listen address | +| `DEVCLOUD_S3_STORAGE` | `/data` | Bucket storage root (required) | +| `DEVCLOUD_S3_AUTH_MODE` | `relaxed` | `relaxed` or `strict`; any other value refuses to start | +| `DEVCLOUD_S3_ACCESS_KEY_ID` | empty | Access key, required in strict mode | +| `DEVCLOUD_S3_SECRET_ACCESS_KEY` | empty | Secret key, required in strict mode | +| `DEVCLOUD_S3_REGION` | `us-east-1` | Region accepted in the SigV4 credential scope | +| `DEVCLOUD_S3_CORS_ALLOW_ORIGINS` | empty | Comma-separated origins (`*` and one-wildcard patterns allowed) for buckets without their own CORS configuration | +| `DEVCLOUD_S3_CONSOLE_ADDR` | `0.0.0.0:9001` | Console listen address; empty disables the console | +| `DEVCLOUD_S3_CONSOLE_ALLOWED_HOSTS` | empty | Extra comma-separated console Host names; `*` disables the check | + +## Signature verification + +Relaxed mode accepts every request. To verify SigV4: + +```sh +docker run --rm -p 127.0.0.1:9000:9000 -p 127.0.0.1:9001:9001 \ + -e DEVCLOUD_S3_AUTH_MODE=strict \ + -e DEVCLOUD_S3_ACCESS_KEY_ID=devkey \ + -e DEVCLOUD_S3_SECRET_ACCESS_KEY=devsecret \ + devcloud-s3 +``` + +Strict mode checks header-signed requests, presigned URLs (GET and PUT, including +expiry), the payload hash, and `aws-chunked` uploads +(`STREAMING-AWS4-HMAC-SHA256-PAYLOAD[-TRAILER]`, whose chunks and trailer are each +verified, and `STREAMING-UNSIGNED-PAYLOAD-TRAILER`). It also requires: + +- `x-amz-date` within 15 minutes of the server clock (`RequestTimeTooSkewed`) +- `host` and every `x-amz-*` header to be signed, except `x-amz-user-agent` and + `x-amz-content-sha256` (whose value is signed as the payload hash) +- the credential scope to match the configured access key, region, and request date + +Trailer checksums such as `x-amz-checksum-crc32` are accepted but not validated. +SigV2 and SigV4a are not implemented. + +## CORS + +Without configuration no cross-origin access is granted. Configure a bucket with +the standard API: + +```sh +aws --endpoint-url http://127.0.0.1:9000 s3api put-bucket-cors --bucket demo \ + --cors-configuration '{"CORSRules":[{"AllowedOrigins":["http://localhost:5173"], + "AllowedMethods":["GET","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"]}]}' +``` + +Or set a default for every bucket without its own rules, which also covers +`ListBuckets` and creating buckets: + +```sh +docker run ... -e DEVCLOUD_S3_CORS_ALLOW_ORIGINS=http://localhost:5173 devcloud-s3 +``` + +Preflight `OPTIONS` needs no signature, since browsers never sign it; the actual +request is still verified in strict mode. Avoid `*` with relaxed auth on a machine +that browses the web: any site could then read and change your local buckets. + +## Web console + +The console lists, creates, and deletes buckets, and browses, uploads, downloads, +and deletes objects. Mutations go through the S3 provider protocol, so they behave +like API calls. + +- **Relaxed mode:** no login. Publish port 9001 on `127.0.0.1` as shown above. +- **Strict mode:** HTTP Basic, with the access key as the user name and the secret + key as the password. Basic credentials travel in clear text, so use TLS in front + of it outside a local machine. +- **Other hostnames:** names containing a dot are refused to prevent DNS rebinding. + To reach the console as `s3.dev.example.com`, set + `DEVCLOUD_S3_CONSOLE_ALLOWED_HOSTS=s3.dev.example.com`. + +The UI source is `web/s3-console`. `npm run build` there writes +`services/s3/assets/console`, which is committed and embedded into the binary. diff --git a/services/s3/assets/console/assets/index-Bxmjs9G2.css b/services/s3/assets/console/assets/index-Bxmjs9G2.css new file mode 100644 index 0000000..c3b5721 --- /dev/null +++ b/services/s3/assets/console/assets/index-Bxmjs9G2.css @@ -0,0 +1 @@ +:root{color-scheme:light;--surface-base: #f7f8f5;--surface-panel: #ffffff;--surface-panel-muted: #fbfcf9;--surface-subtle: #eef1ec;--surface-active: #f3faf6;--surface-overlay: rgba(255, 255, 255, .86);--surface-scrim: rgba(16, 21, 17, .32);--surface-success-soft: #ddefe7;--surface-warning-soft: #f4ece2;--surface-error-soft: #f8e7e3;--surface-danger-soft: #fff8f6;--surface-segment-active: #e7f3ec;--text-primary: #1d211c;--text-secondary: #5f675d;--border-default: #d9ded5;--border-active: #9fb6a7;--border-input: #7e897b;--border-empty: #c8d1c2;--border-danger: #e3b8b3;--border-danger-soft: #e0b4ab;--accent-primary: #176b4d;--accent-object: #245b8f;--accent-warning: #9a5b13;--accent-danger: #b42318;--accent-on-fill: #ffffff;--code-background: #101511;--code-text: #e8efe7;--radius-md: 8px;--shadow-panel: 0 1px 2px rgba(20, 28, 18, .05), 0 14px 34px rgba(20, 28, 18, .08);--focus-ring-color: #cfe5d9}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--surface-base: #121613;--surface-panel: #181d19;--surface-panel-muted: #1e2420;--surface-subtle: #242b26;--surface-active: #223329;--surface-segment-active: #263d31;--surface-error-soft: #361d1b;--surface-warning-soft: #30281e;--surface-scrim: rgba(0, 0, 0, .6);--text-primary: #e8efe7;--text-secondary: #9ba398;--border-default: #2f3831;--border-active: #44634f;--border-input: #7a897e;--border-empty: #44634f;--border-danger: #5c2824;--accent-primary: #3eb885;--accent-object: #64a0dc;--accent-warning: #e6b56e;--accent-danger: #f87171;--accent-on-fill: #181d19;--focus-ring-color: #24583e;--code-background: #0d110e;--code-text: #e8efe7}}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--surface-base);color:var(--text-primary);font:13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Hiragino Sans,Noto Sans JP,sans-serif}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed}[hidden]{display:none!important}:is(button,input,a):focus-visible{outline:2px solid var(--accent-primary);outline-offset:2px}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.app{display:flex;flex-direction:column;height:100%}.topbar{display:flex;align-items:baseline;gap:12px;padding:10px 16px;background:var(--surface-panel);border-bottom:1px solid var(--border-default)}.topbar h1{margin:0;font-size:15px}.meta{color:var(--text-secondary)}.layout{display:flex;flex:1;min-height:0}.sidebar{width:280px;flex-shrink:0;overflow-y:auto;padding:12px;background:var(--surface-panel-muted);border-right:1px solid var(--border-default)}.new-bucket label{display:block;margin-bottom:4px;font-weight:600}.row{display:flex;align-items:center;gap:6px}.new-bucket input{flex:1;min-width:0;min-height:32px;padding:5px 8px;color:var(--text-primary);background:var(--surface-panel);border:1px solid var(--border-input);border-radius:var(--radius-md)}.bucket-list{margin:12px 0 0;padding:0;list-style:none}.bucket{display:flex;justify-content:space-between;gap:8px;width:100%;padding:7px 8px;color:inherit;text-align:left;background:none;border:1px solid transparent;border-radius:var(--radius-md)}.bucket:hover{background:var(--surface-subtle)}.bucket.active{background:var(--surface-active);border-color:var(--border-active);font-weight:600}.bucket-name{overflow-wrap:anywhere}.empty-note{margin:12px 8px}.content{flex:1;min-width:0;overflow:auto;padding:16px}.toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}.crumbs{font-size:14px;overflow-wrap:anywhere}.separator{margin:0 4px;color:var(--text-secondary)}.link{padding:0;color:var(--accent-object);text-align:left;overflow-wrap:anywhere;background:none;border:0}.link:hover{text-decoration:underline}.button{display:inline-flex;align-items:center;justify-content:center;min-height:32px;padding:5px 10px;color:var(--text-primary);font-weight:600;text-decoration:none;white-space:nowrap;background:var(--surface-panel);border:1px solid var(--border-default);border-radius:var(--radius-md)}.button:hover:not(:disabled){background:var(--surface-subtle)}.button:disabled{opacity:.58}.button.danger{color:var(--accent-danger);border-color:var(--border-danger)}.button.primary,.button.primary:hover:not(:disabled){color:var(--accent-on-fill);background:var(--accent-primary);border-color:var(--accent-primary)}.button.danger.primary,.button.danger.primary:hover:not(:disabled){color:var(--accent-on-fill);background:var(--accent-danger);border-color:var(--accent-danger)}.objects{width:100%;border-collapse:collapse;background:var(--surface-panel);border:1px solid var(--border-default)}.objects th,.objects td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--border-default)}.objects th{color:var(--text-secondary);font-weight:600}.objects .numeric{text-align:right;white-space:nowrap}.objects .name{overflow-wrap:anywhere}.objects .actions{display:flex;justify-content:flex-end;gap:6px}.placeholder{margin:32px 0;color:var(--text-secondary);text-align:center}.banner{margin:0 0 12px;padding:7px 10px;background:var(--surface-success-soft);border-radius:var(--radius-md)}.banner.error{color:var(--accent-danger);background:var(--surface-error-soft)}@media(max-width:720px){.layout{flex-direction:column}.sidebar{width:100%;max-height:40%;border-right:0;border-bottom:1px solid var(--border-default)}} diff --git a/services/s3/assets/console/assets/index-CwBD1Nc8.js b/services/s3/assets/console/assets/index-CwBD1Nc8.js new file mode 100644 index 0000000..b549e4d --- /dev/null +++ b/services/s3/assets/console/assets/index-CwBD1Nc8.js @@ -0,0 +1,40 @@ +(function(){const R=document.createElement("link").relList;if(R&&R.supports&&R.supports("modulepreload"))return;for(const D of document.querySelectorAll('link[rel="modulepreload"]'))J(D);new MutationObserver(D=>{for(const $ of D)if($.type==="childList")for(const se of $.addedNodes)se.tagName==="LINK"&&se.rel==="modulepreload"&&J(se)}).observe(document,{childList:!0,subtree:!0});function h(D){const $={};return D.integrity&&($.integrity=D.integrity),D.referrerPolicy&&($.referrerPolicy=D.referrerPolicy),D.crossOrigin==="use-credentials"?$.credentials="include":D.crossOrigin==="anonymous"?$.credentials="omit":$.credentials="same-origin",$}function J(D){if(D.ep)return;D.ep=!0;const $=h(D);fetch(D.href,$)}})();var Ni={exports:{}},wr={},Pi={exports:{}},I={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var za;function Bf(){if(za)return I;za=1;var S=Symbol.for("react.element"),R=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),J=Symbol.for("react.strict_mode"),D=Symbol.for("react.profiler"),$=Symbol.for("react.provider"),se=Symbol.for("react.context"),fe=Symbol.for("react.forward_ref"),W=Symbol.for("react.suspense"),xe=Symbol.for("react.memo"),ye=Symbol.for("react.lazy"),Q=Symbol.iterator;function G(c){return c===null||typeof c!="object"?null:(c=Q&&c[Q]||c["@@iterator"],typeof c=="function"?c:null)}var he={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Oe=Object.assign,Z={};function U(c,y,M){this.props=c,this.context=y,this.refs=Z,this.updater=M||he}U.prototype.isReactComponent={},U.prototype.setState=function(c,y){if(typeof c!="object"&&typeof c!="function"&&c!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,c,y,"setState")},U.prototype.forceUpdate=function(c){this.updater.enqueueForceUpdate(this,c,"forceUpdate")};function et(){}et.prototype=U.prototype;function Ke(c,y,M){this.props=c,this.context=y,this.refs=Z,this.updater=M||he}var K=Ke.prototype=new et;K.constructor=Ke,Oe(K,U.prototype),K.isPureReactComponent=!0;var ee=Array.isArray,De=Object.prototype.hasOwnProperty,ge={current:null},de={key:!0,ref:!0,__self:!0,__source:!0};function we(c,y,M){var F,A={},V=null,q=null;if(y!=null)for(F in y.ref!==void 0&&(q=y.ref),y.key!==void 0&&(V=""+y.key),y)De.call(y,F)&&!de.hasOwnProperty(F)&&(A[F]=y[F]);var Y=arguments.length-2;if(Y===1)A.children=M;else if(1>>1,y=m[c];if(0>>1;cD(A,x))VD(q,A)?(m[c]=q,m[V]=x,c=V):(m[c]=A,m[F]=x,c=F);else if(VD(q,x))m[c]=q,m[V]=x,c=V;else break e}}return E}function D(m,E){var x=m.sortIndex-E.sortIndex;return x!==0?x:m.id-E.id}if(typeof performance=="object"&&typeof performance.now=="function"){var $=performance;S.unstable_now=function(){return $.now()}}else{var se=Date,fe=se.now();S.unstable_now=function(){return se.now()-fe}}var W=[],xe=[],ye=1,Q=null,G=3,he=!1,Oe=!1,Z=!1,U=typeof setTimeout=="function"?setTimeout:null,et=typeof clearTimeout=="function"?clearTimeout:null,Ke=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function K(m){for(var E=h(xe);E!==null;){if(E.callback===null)J(xe);else if(E.startTime<=m)J(xe),E.sortIndex=E.expirationTime,R(W,E);else break;E=h(xe)}}function ee(m){if(Z=!1,K(m),!Oe)if(h(W)!==null)Oe=!0,ze(De);else{var E=h(xe);E!==null&&re(ee,E.startTime-m)}}function De(m,E){Oe=!1,Z&&(Z=!1,et(we),we=-1),he=!0;var x=G;try{for(K(E),Q=h(W);Q!==null&&(!(Q.expirationTime>E)||m&&!kt());){var c=Q.callback;if(typeof c=="function"){Q.callback=null,G=Q.priorityLevel;var y=c(Q.expirationTime<=E);E=S.unstable_now(),typeof y=="function"?Q.callback=y:Q===h(W)&&J(W),K(E)}else J(W);Q=h(W)}if(Q!==null)var M=!0;else{var F=h(xe);F!==null&&re(ee,F.startTime-E),M=!1}return M}finally{Q=null,G=x,he=!1}}var ge=!1,de=null,we=-1,Ye=5,Be=-1;function kt(){return!(S.unstable_now()-Bem||125c?(m.sortIndex=x,R(xe,m),h(W)===null&&m===h(xe)&&(Z?(et(we),we=-1):Z=!0,re(ee,x-c))):(m.sortIndex=y,R(W,m),Oe||he||(Oe=!0,ze(De))),m},S.unstable_shouldYield=kt,S.unstable_wrapCallback=function(m){var E=G;return function(){var x=G;G=E;try{return m.apply(this,arguments)}finally{G=x}}}})(Ti)),Ti}var Oa;function Hf(){return Oa||(Oa=1,Li.exports=Vf()),Li.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Da;function Wf(){if(Da)return Qe;Da=1;var S=ji(),R=Hf();function h(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),W=Object.prototype.hasOwnProperty,xe=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ye={},Q={};function G(e){return W.call(Q,e)?!0:W.call(ye,e)?!1:xe.test(e)?Q[e]=!0:(ye[e]=!0,!1)}function he(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Oe(e,t,n,r){if(t===null||typeof t>"u"||he(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Z(e,t,n,r,l,u,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=i}var U={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){U[e]=new Z(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];U[t]=new Z(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){U[e]=new Z(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){U[e]=new Z(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){U[e]=new Z(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){U[e]=new Z(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){U[e]=new Z(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){U[e]=new Z(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){U[e]=new Z(e,5,!1,e.toLowerCase(),null,!1,!1)});var et=/[\-:]([a-z])/g;function Ke(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(et,Ke);U[t]=new Z(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(et,Ke);U[t]=new Z(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(et,Ke);U[t]=new Z(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){U[e]=new Z(e,1,!1,e.toLowerCase(),null,!1,!1)}),U.xlinkHref=new Z("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){U[e]=new Z(e,1,!1,e.toLowerCase(),null,!0,!0)});function K(e,t,n,r){var l=U.hasOwnProperty(t)?U[t]:null;(l!==null?l.type!==0:r||!(2o||l[i]!==u[o]){var s=` +`+l[i].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=i&&0<=o);break}}}finally{M=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?y(e):""}function A(e){switch(e.tag){case 5:return y(e.type);case 16:return y("Lazy");case 13:return y("Suspense");case 19:return y("SuspenseList");case 0:case 2:case 15:return e=F(e.type,!1),e;case 11:return e=F(e.type.render,!1),e;case 1:return e=F(e.type,!0),e;default:return""}}function V(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case de:return"Fragment";case ge:return"Portal";case Ye:return"Profiler";case we:return"StrictMode";case Me:return"Suspense";case Xe:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case kt:return(e.displayName||"Context")+".Consumer";case Be:return(e._context.displayName||"Context")+".Provider";case tt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ie:return t=e.displayName||null,t!==null?t:V(e.type)||"Memo";case ze:t=e._payload,e=e._init;try{return V(e(t))}catch{}}return null}function q(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return V(t);case 8:return t===we?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Y(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ge(e){var t=le(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,u.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function kr(e){e._valueTracker||(e._valueTracker=Ge(e))}function Oi(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=le(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Sr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Ol(e,t){var n=t.checked;return x({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Di(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Y(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Mi(e,t){t=t.checked,t!=null&&K(e,"checked",t,!1)}function Dl(e,t){Mi(e,t);var n=Y(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ml(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ml(e,t.type,Y(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ii(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ml(e,t,n){(t!=="number"||Sr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Dn=Array.isArray;function an(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=xr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Mn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var In={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Va=["Webkit","ms","Moz","O"];Object.keys(In).forEach(function(e){Va.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),In[t]=In[e]})});function Vi(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||In.hasOwnProperty(e)&&In[e]?(""+t).trim():t+"px"}function Hi(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Vi(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Ha=x({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Ul(e,t){if(t){if(Ha[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(h(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(h(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(h(61))}if(t.style!=null&&typeof t.style!="object")throw Error(h(62))}}function Bl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var $l=null;function Al(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Vl=null,cn=null,fn=null;function Wi(e){if(e=lr(e)){if(typeof Vl!="function")throw Error(h(280));var t=e.stateNode;t&&(t=Qr(t),Vl(e.stateNode,e.type,t))}}function Qi(e){cn?fn?fn.push(e):fn=[e]:cn=e}function Ki(){if(cn){var e=cn,t=fn;if(fn=cn=null,Wi(e),t)for(e=0;e>>=0,e===0?32:31-(ec(e)/tc|0)|0}var Pr=64,zr=4194304;function $n(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Lr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,i=n&268435455;if(i!==0){var o=i&~l;o!==0?r=$n(o):(u&=i,u!==0&&(r=$n(u)))}else i=n&~l,i!==0?r=$n(i):u!==0&&(r=$n(u));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function An(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-st(t),e[t]=n}function uc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Gn),So=" ",xo=!1;function Eo(e,t){switch(e){case"keyup":return Oc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Co(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var mn=!1;function Mc(e,t){switch(e){case"compositionend":return Co(t);case"keypress":return t.which!==32?null:(xo=!0,So);case"textInput":return e=t.data,e===So&&xo?null:e;default:return null}}function Ic(e,t){if(mn)return e==="compositionend"||!iu&&Eo(e,t)?(e=ho(),Dr=eu=Ot=null,mn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=jo(n)}}function Oo(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Oo(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Do(){for(var e=window,t=Sr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Sr(e.document)}return t}function au(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Qc(e){var t=Do(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Oo(n.ownerDocument.documentElement,n)){if(r!==null&&au(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=Ro(n,u);var i=Ro(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,hn=null,cu=null,bn=null,fu=!1;function Mo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;fu||hn==null||hn!==Sr(r)||(r=hn,"selectionStart"in r&&au(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),bn&&qn(bn,r)||(bn=r,r=Vr(cu,"onSelect"),0kn||(e.current=Eu[kn],Eu[kn]=null,kn--)}function b(e,t){kn++,Eu[kn]=e.current,e.current=t}var Ft={},Le=It(Ft),$e=It(!1),Zt=Ft;function Sn(e,t){var n=e.type.contextTypes;if(!n)return Ft;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ae(e){return e=e.childContextTypes,e!=null}function Kr(){ne($e),ne(Le)}function Zo(e,t,n){if(Le.current!==Ft)throw Error(h(168));b(Le,t),b($e,n)}function Jo(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(h(108,q(e)||"Unknown",l));return x({},n,r)}function Yr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ft,Zt=Le.current,b(Le,e),b($e,$e.current),!0}function qo(e,t,n){var r=e.stateNode;if(!r)throw Error(h(169));n?(e=Jo(e,t,Zt),r.__reactInternalMemoizedMergedChildContext=e,ne($e),ne(Le),b(Le,e)):ne($e),b($e,n)}var xt=null,Xr=!1,Cu=!1;function bo(e){xt===null?xt=[e]:xt.push(e)}function rf(e){Xr=!0,bo(e)}function Ut(){if(!Cu&&xt!==null){Cu=!0;var e=0,t=X;try{var n=xt;for(X=1;e>=i,l-=i,Et=1<<32-st(t)+l|n<O?(_e=j,j=null):_e=j.sibling;var H=v(f,j,d[O],k);if(H===null){j===null&&(j=_e);break}e&&j&&H.alternate===null&&t(f,j),a=u(H,a,O),T===null?L=H:T.sibling=H,T=H,j=_e}if(O===d.length)return n(f,j),ue&&qt(f,O),L;if(j===null){for(;OO?(_e=j,j=null):_e=j.sibling;var Yt=v(f,j,H.value,k);if(Yt===null){j===null&&(j=_e);break}e&&j&&Yt.alternate===null&&t(f,j),a=u(Yt,a,O),T===null?L=Yt:T.sibling=Yt,T=Yt,j=_e}if(H.done)return n(f,j),ue&&qt(f,O),L;if(j===null){for(;!H.done;O++,H=d.next())H=w(f,H.value,k),H!==null&&(a=u(H,a,O),T===null?L=H:T.sibling=H,T=H);return ue&&qt(f,O),L}for(j=r(f,j);!H.done;O++,H=d.next())H=C(j,f,O,H.value,k),H!==null&&(e&&H.alternate!==null&&j.delete(H.key===null?O:H.key),a=u(H,a,O),T===null?L=H:T.sibling=H,T=H);return e&&j.forEach(function(Uf){return t(f,Uf)}),ue&&qt(f,O),L}function me(f,a,d,k){if(typeof d=="object"&&d!==null&&d.type===de&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case De:e:{for(var L=d.key,T=a;T!==null;){if(T.key===L){if(L=d.type,L===de){if(T.tag===7){n(f,T.sibling),a=l(T,d.props.children),a.return=f,f=a;break e}}else if(T.elementType===L||typeof L=="object"&&L!==null&&L.$$typeof===ze&&us(L)===T.type){n(f,T.sibling),a=l(T,d.props),a.ref=ur(f,T,d),a.return=f,f=a;break e}n(f,T);break}else t(f,T);T=T.sibling}d.type===de?(a=on(d.props.children,f.mode,k,d.key),a.return=f,f=a):(k=xl(d.type,d.key,d.props,null,f.mode,k),k.ref=ur(f,a,d),k.return=f,f=k)}return i(f);case ge:e:{for(T=d.key;a!==null;){if(a.key===T)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=Si(d,f.mode,k),a.return=f,f=a}return i(f);case ze:return T=d._init,me(f,a,T(d._payload),k)}if(Dn(d))return P(f,a,d,k);if(E(d))return z(f,a,d,k);qr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=ki(d,f.mode,k),a.return=f,f=a),i(f)):n(f,a)}return me}var _n=is(!0),os=is(!1),br=It(null),el=null,Nn=null,Tu=null;function ju(){Tu=Nn=el=null}function Ru(e){var t=br.current;ne(br),e._currentValue=t}function Ou(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Pn(e,t){el=e,Tu=Nn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ve=!0),e.firstContext=null)}function lt(e){var t=e._currentValue;if(Tu!==e)if(e={context:e,memoizedValue:t,next:null},Nn===null){if(el===null)throw Error(h(308));Nn=e,el.dependencies={lanes:0,firstContext:e}}else Nn=Nn.next=e;return t}var bt=null;function Du(e){bt===null?bt=[e]:bt.push(e)}function ss(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Du(t)):(n.next=l.next,l.next=n),t.interleaved=n,_t(e,r)}function _t(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Bt=!1;function Mu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function as(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Nt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function $t(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(B&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,_t(e,n)}return l=r.interleaved,l===null?(t.next=t,Du(r)):(t.next=l.next,l.next=t),r.interleaved=t,_t(e,n)}function tl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gl(e,n)}}function cs(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=i:u=u.next=i,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function nl(e,t,n,r){var l=e.updateQueue;Bt=!1;var u=l.firstBaseUpdate,i=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var s=o,p=s.next;s.next=null,i===null?u=p:i.next=p,i=s;var g=e.alternate;g!==null&&(g=g.updateQueue,o=g.lastBaseUpdate,o!==i&&(o===null?g.firstBaseUpdate=p:o.next=p,g.lastBaseUpdate=s))}if(u!==null){var w=l.baseState;i=0,g=p=s=null,o=u;do{var v=o.lane,C=o.eventTime;if((r&v)===v){g!==null&&(g=g.next={eventTime:C,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var P=e,z=o;switch(v=t,C=n,z.tag){case 1:if(P=z.payload,typeof P=="function"){w=P.call(C,w,v);break e}w=P;break e;case 3:P.flags=P.flags&-65537|128;case 0:if(P=z.payload,v=typeof P=="function"?P.call(C,w,v):P,v==null)break e;w=x({},w,v);break e;case 2:Bt=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,v=l.effects,v===null?l.effects=[o]:v.push(o))}else C={eventTime:C,lane:v,tag:o.tag,payload:o.payload,callback:o.callback,next:null},g===null?(p=g=C,s=w):g=g.next=C,i|=v;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;v=o,o=v.next,v.next=null,l.lastBaseUpdate=v,l.shared.pending=null}}while(!0);if(g===null&&(s=w),l.baseState=s,l.firstBaseUpdate=p,l.lastBaseUpdate=g,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);nn|=i,e.lanes=i,e.memoizedState=w}}function fs(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=$u.transition;$u.transition={};try{e(!1),t()}finally{X=n,$u.transition=r}}function Ts(){return ut().memoizedState}function sf(e,t,n){var r=Wt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},js(e))Rs(t,n);else if(n=ss(e,t,n,r),n!==null){var l=Ue();mt(n,e,r,l),Os(n,t,r)}}function af(e,t,n){var r=Wt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(js(e))Rs(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var i=t.lastRenderedState,o=u(i,n);if(l.hasEagerState=!0,l.eagerState=o,at(o,i)){var s=t.interleaved;s===null?(l.next=l,Du(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=ss(e,t,l,r),n!==null&&(l=Ue(),mt(n,e,r,l),Os(n,t,r))}}function js(e){var t=e.alternate;return e===oe||t!==null&&t===oe}function Rs(e,t){ar=ul=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Os(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gl(e,n)}}var sl={readContext:lt,useCallback:Te,useContext:Te,useEffect:Te,useImperativeHandle:Te,useInsertionEffect:Te,useLayoutEffect:Te,useMemo:Te,useReducer:Te,useRef:Te,useState:Te,useDebugValue:Te,useDeferredValue:Te,useTransition:Te,useMutableSource:Te,useSyncExternalStore:Te,useId:Te,unstable_isNewReconciler:!1},cf={readContext:lt,useCallback:function(e,t){return gt().memoizedState=[e,t===void 0?null:t],e},useContext:lt,useEffect:xs,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,il(4194308,4,_s.bind(null,t,e),n)},useLayoutEffect:function(e,t){return il(4194308,4,e,t)},useInsertionEffect:function(e,t){return il(4,2,e,t)},useMemo:function(e,t){var n=gt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=gt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=sf.bind(null,oe,e),[r.memoizedState,e]},useRef:function(e){var t=gt();return e={current:e},t.memoizedState=e},useState:ks,useDebugValue:Yu,useDeferredValue:function(e){return gt().memoizedState=e},useTransition:function(){var e=ks(!1),t=e[0];return e=of.bind(null,e[1]),gt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=oe,l=gt();if(ue){if(n===void 0)throw Error(h(407));n=n()}else{if(n=t(),Ce===null)throw Error(h(349));(tn&30)!==0||hs(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,xs(ys.bind(null,r,u,e),[e]),r.flags|=2048,dr(9,vs.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=gt(),t=Ce.identifierPrefix;if(ue){var n=Ct,r=Et;n=(r&~(1<<32-st(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=cr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[vt]=t,e[rr]=r,bs(e,t,!1,!1),t.stateNode=e;e:{switch(i=Bl(n,r),n){case"dialog":te("cancel",e),te("close",e),l=r;break;case"iframe":case"object":case"embed":te("load",e),l=r;break;case"video":case"audio":for(l=0;lRn&&(t.flags|=128,r=!0,pr(u,!1),t.lanes=4194304)}else{if(!r)if(e=rl(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),pr(u,!0),u.tail===null&&u.tailMode==="hidden"&&!i.alternate&&!ue)return je(t),null}else 2*pe()-u.renderingStartTime>Rn&&n!==1073741824&&(t.flags|=128,r=!0,pr(u,!1),t.lanes=4194304);u.isBackwards?(i.sibling=t.child,t.child=i):(n=u.last,n!==null?n.sibling=i:t.child=i,u.last=i)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=pe(),t.sibling=null,n=ie.current,b(ie,r?n&1|2:n&1),t):(je(t),null);case 22:case 23:return yi(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(be&1073741824)!==0&&(je(t),t.subtreeFlags&6&&(t.flags|=8192)):je(t),null;case 24:return null;case 25:return null}throw Error(h(156,t.tag))}function gf(e,t){switch(Nu(t),t.tag){case 1:return Ae(t.type)&&Kr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return zn(),ne($e),ne(Le),Bu(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Fu(t),null;case 13:if(ne(ie),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(h(340));Cn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ne(ie),null;case 4:return zn(),null;case 10:return Ru(t.type._context),null;case 22:case 23:return yi(),null;case 24:return null;default:return null}}var dl=!1,Re=!1,wf=typeof WeakSet=="function"?WeakSet:Set,N=null;function Tn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){ae(e,t,r)}else n.current=null}function ui(e,t,n){try{n()}catch(r){ae(e,t,r)}}var na=!1;function kf(e,t){if(yu=Rr,e=Do(),au(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var i=0,o=-1,s=-1,p=0,g=0,w=e,v=null;t:for(;;){for(var C;w!==n||l!==0&&w.nodeType!==3||(o=i+l),w!==u||r!==0&&w.nodeType!==3||(s=i+r),w.nodeType===3&&(i+=w.nodeValue.length),(C=w.firstChild)!==null;)v=w,w=C;for(;;){if(w===e)break t;if(v===n&&++p===l&&(o=i),v===u&&++g===r&&(s=i),(C=w.nextSibling)!==null)break;w=v,v=w.parentNode}w=C}n=o===-1||s===-1?null:{start:o,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(gu={focusedElem:e,selectionRange:n},Rr=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var P=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(P!==null){var z=P.memoizedProps,me=P.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?z:ft(t.type,z),me);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(h(163))}}catch(k){ae(t,t.return,k)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return P=na,na=!1,P}function mr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&ui(t,n,u)}l=l.next}while(l!==r)}}function pl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ii(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function ra(e){var t=e.alternate;t!==null&&(e.alternate=null,ra(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[vt],delete t[rr],delete t[xu],delete t[tf],delete t[nf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function la(e){return e.tag===5||e.tag===3||e.tag===4}function ua(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||la(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function oi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Wr));else if(r!==4&&(e=e.child,e!==null))for(oi(e,t,n),e=e.sibling;e!==null;)oi(e,t,n),e=e.sibling}function si(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(si(e,t,n),e=e.sibling;e!==null;)si(e,t,n),e=e.sibling}var Ne=null,dt=!1;function At(e,t,n){for(n=n.child;n!==null;)ia(e,t,n),n=n.sibling}function ia(e,t,n){if(ht&&typeof ht.onCommitFiberUnmount=="function")try{ht.onCommitFiberUnmount(Nr,n)}catch{}switch(n.tag){case 5:Re||Tn(n,t);case 6:var r=Ne,l=dt;Ne=null,At(e,t,n),Ne=r,dt=l,Ne!==null&&(dt?(e=Ne,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ne.removeChild(n.stateNode));break;case 18:Ne!==null&&(dt?(e=Ne,n=n.stateNode,e.nodeType===8?Su(e.parentNode,n):e.nodeType===1&&Su(e,n),Kn(e)):Su(Ne,n.stateNode));break;case 4:r=Ne,l=dt,Ne=n.stateNode.containerInfo,dt=!0,At(e,t,n),Ne=r,dt=l;break;case 0:case 11:case 14:case 15:if(!Re&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,i=u.destroy;u=u.tag,i!==void 0&&((u&2)!==0||(u&4)!==0)&&ui(n,t,i),l=l.next}while(l!==r)}At(e,t,n);break;case 1:if(!Re&&(Tn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){ae(n,t,o)}At(e,t,n);break;case 21:At(e,t,n);break;case 22:n.mode&1?(Re=(r=Re)||n.memoizedState!==null,At(e,t,n),Re=r):At(e,t,n);break;default:At(e,t,n)}}function oa(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new wf),t.forEach(function(r){var l=Lf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function pt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~u}if(r=l,r=pe()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*xf(r/1960))-r,10e?16:e,Ht===null)var r=!1;else{if(e=Ht,Ht=null,gl=0,(B&6)!==0)throw Error(h(331));var l=B;for(B|=4,N=e.current;N!==null;){var u=N,i=u.child;if((N.flags&16)!==0){var o=u.deletions;if(o!==null){for(var s=0;spe()-fi?ln(e,0):ci|=n),We(e,t)}function ka(e,t){t===0&&((e.mode&1)===0?t=1:(t=zr,zr<<=1,(zr&130023424)===0&&(zr=4194304)));var n=Ue();e=_t(e,t),e!==null&&(An(e,t,n),We(e,n))}function zf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ka(e,n)}function Lf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(h(314))}r!==null&&r.delete(t),ka(e,n)}var Sa;Sa=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||$e.current)Ve=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ve=!1,vf(e,t,n);Ve=(e.flags&131072)!==0}else Ve=!1,ue&&(t.flags&1048576)!==0&&es(t,Zr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;fl(e,t),e=t.pendingProps;var l=Sn(t,Le.current);Pn(t,n),l=Vu(null,t,r,e,l,n);var u=Hu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ae(r)?(u=!0,Yr(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Mu(t),l.updater=al,t.stateNode=l,l._reactInternals=t,Gu(t,r,e,n),t=bu(null,t,r,!0,u,n)):(t.tag=0,ue&&u&&_u(t),Fe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(fl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=jf(r),e=ft(r,e),l){case 0:t=qu(null,t,r,e,n);break e;case 1:t=Ys(null,t,r,e,n);break e;case 11:t=Vs(null,t,r,e,n);break e;case 14:t=Hs(null,t,r,ft(r.type,e),n);break e}throw Error(h(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),qu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Ys(e,t,r,l,n);case 3:e:{if(Xs(t),e===null)throw Error(h(387));r=t.pendingProps,u=t.memoizedState,l=u.element,as(e,t),nl(t,r,null,n);var i=t.memoizedState;if(r=i.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=Ln(Error(h(423)),t),t=Gs(e,t,r,n,l);break e}else if(r!==l){l=Ln(Error(h(424)),t),t=Gs(e,t,r,n,l);break e}else for(qe=Mt(t.stateNode.containerInfo.firstChild),Je=t,ue=!0,ct=null,n=os(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Cn(),r===l){t=Pt(e,t,n);break e}Fe(e,t,r,n)}t=t.child}return t;case 5:return ds(t),e===null&&zu(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,i=l.children,wu(r,l)?i=null:u!==null&&wu(r,u)&&(t.flags|=32),Ks(e,t),Fe(e,t,i,n),t.child;case 6:return e===null&&zu(t),null;case 13:return Zs(e,t,n);case 4:return Iu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=_n(t,null,r,n):Fe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Vs(e,t,r,l,n);case 7:return Fe(e,t,t.pendingProps,n),t.child;case 8:return Fe(e,t,t.pendingProps.children,n),t.child;case 12:return Fe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,i=l.value,b(br,r._currentValue),r._currentValue=i,u!==null)if(at(u.value,i)){if(u.children===l.children&&!$e.current){t=Pt(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var o=u.dependencies;if(o!==null){i=u.child;for(var s=o.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=Nt(-1,n&-n),s.tag=2;var p=u.updateQueue;if(p!==null){p=p.shared;var g=p.pending;g===null?s.next=s:(s.next=g.next,g.next=s),p.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),Ou(u.return,n,t),o.lanes|=n;break}s=s.next}}else if(u.tag===10)i=u.type===t.type?null:u.child;else if(u.tag===18){if(i=u.return,i===null)throw Error(h(341));i.lanes|=n,o=i.alternate,o!==null&&(o.lanes|=n),Ou(i,n,t),i=u.sibling}else i=u.child;if(i!==null)i.return=u;else for(i=u;i!==null;){if(i===t){i=null;break}if(u=i.sibling,u!==null){u.return=i.return,i=u;break}i=i.return}u=i}Fe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Pn(t,n),l=lt(l),r=r(l),t.flags|=1,Fe(e,t,r,n),t.child;case 14:return r=t.type,l=ft(r,t.pendingProps),l=ft(r.type,l),Hs(e,t,r,l,n);case 15:return Ws(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),fl(e,t),t.tag=1,Ae(r)?(e=!0,Yr(t)):e=!1,Pn(t,n),Ms(t,r,l),Gu(t,r,l,n),bu(null,t,r,!0,e,n);case 19:return qs(e,t,n);case 22:return Qs(e,t,n)}throw Error(h(156,t.tag))};function xa(e,t){return eo(e,t)}function Tf(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ot(e,t,n,r){return new Tf(e,t,n,r)}function wi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function jf(e){if(typeof e=="function")return wi(e)?1:0;if(e!=null){if(e=e.$$typeof,e===tt)return 11;if(e===Ie)return 14}return 2}function Kt(e,t){var n=e.alternate;return n===null?(n=ot(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function xl(e,t,n,r,l,u){var i=2;if(r=e,typeof e=="function")wi(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case de:return on(n.children,l,u,t);case we:i=8,l|=8;break;case Ye:return e=ot(12,n,t,l|2),e.elementType=Ye,e.lanes=u,e;case Me:return e=ot(13,n,t,l),e.elementType=Me,e.lanes=u,e;case Xe:return e=ot(19,n,t,l),e.elementType=Xe,e.lanes=u,e;case re:return El(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Be:i=10;break e;case kt:i=9;break e;case tt:i=11;break e;case Ie:i=14;break e;case ze:i=16,r=null;break e}throw Error(h(130,e==null?e:typeof e,""))}return t=ot(i,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function on(e,t,n,r){return e=ot(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=ot(22,e,r,t),e.elementType=re,e.lanes=n,e.stateNode={isHidden:!1},e}function ki(e,t,n){return e=ot(6,e,null,t),e.lanes=n,e}function Si(e,t,n){return t=ot(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Rf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Xl(0),this.expirationTimes=Xl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Xl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function xi(e,t,n,r,l,u,i,o,s){return e=new Rf(e,t,n,o,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=ot(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Mu(u),e}function Of(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(S)}catch(R){console.error(R)}}return S(),zi.exports=Wf(),zi.exports}var Ia;function Kf(){if(Ia)return Tl;Ia=1;var S=Qf();return Tl.createRoot=S.createRoot,Tl.hydrateRoot=S.hydrateRoot,Tl}var Yf=Kf();const Fa=128*1024*1024;async function sn(S,R){var J;const h=await fetch(S,R);if(!h.ok){const D=await h.json().catch(()=>null);throw new Error((D==null?void 0:D.error)??`Request failed with status ${h.status}`)}return h.status===204||!((J=h.headers.get("content-type"))!=null&&J.includes("json"))?void 0:await h.json()}function Rl(S){return`/api/buckets/${encodeURIComponent(S)}`}function Ri(S,R){return`${Rl(S)}/object?key=${encodeURIComponent(R)}`}const Xf=()=>sn("/api/info"),Gf=async()=>(await sn("/api/buckets")).buckets,Zf=S=>sn(Rl(S),{method:"PUT"}),Jf=S=>sn(Rl(S),{method:"DELETE"}),qf=async(S,R)=>(await sn(`${Rl(S)}/objects?prefix=${encodeURIComponent(R)}`)).objects,bf=(S,R,h)=>sn(Ri(S,R),{method:"PUT",body:h,headers:h.type?{"Content-Type":h.type}:void 0}),ed=(S,R)=>sn(Ri(S,R),{method:"DELETE"});function td(S,R){const h=new Set,J=[];for(const D of S){const $=D.key.indexOf("/",R.length);$===-1?J.push(D):h.add(D.key.slice(0,$+1))}return{folders:[...h].sort(),objects:J}}function Ua(S){if(S<1024)return`${S} B`;const R=["KiB","MiB","GiB","TiB"];let h=S,J=-1;for(;h>=1024&&J{const E=new URLSearchParams;m.bucket&&E.set("bucket",m.bucket),m.prefix&&E.set("prefix",m.prefix);const x=E.toString();window.history.pushState(null,"",x?`/?${x}`:"/"),Q(null),fe(m)},[]);ce.useEffect(()=>{const m=()=>{Q(null),fe($a())};return window.addEventListener("popstate",m),()=>window.removeEventListener("popstate",m)},[]);const we=ce.useCallback(async()=>{J(await Gf())},[]),Ye=ce.useCallback(async()=>{if(De.current.bucket!==K||De.current.prefix!==ee)return;const m=++ge.current;try{const E=K?await qf(K,ee):[];m===ge.current&&$(E)}catch(E){if(m===ge.current)throw E}},[K,ee]);ce.useEffect(()=>{Xf().then(R,m=>he(jl(m))),we().catch(m=>he(jl(m)))},[we]),ce.useEffect(()=>{he(""),$([]),Ye().catch(m=>{$([]),he(jl(m))})},[Ye]);const Be=ce.useCallback(async(m,E)=>{et(!0),he(""),Z("");try{await E(),Z(m)}catch(x){he(jl(x))}finally{et(!1),Q(null)}},[]),kt=m=>{m.preventDefault();const E=W.trim();E&&Be(`Created bucket ${E}`,async()=>{await Zf(E),xe(""),await we(),de({bucket:E,prefix:""})})},tt=()=>Be(`Deleted bucket ${K}`,async()=>{await Jf(K),de({bucket:"",prefix:""}),await we()}),Me=m=>Be(`Deleted ${m}`,async()=>{await ed(K,m),await Promise.all([Ye(),we()])}),Xe=m=>{const E=[...m],x=E.find(c=>c.size>Fa);if(x){he(`${x.name} exceeds the ${Ua(Fa)} request limit.`);return}Be(`Uploaded ${E.length} file(s)`,async()=>{for(const c of E)await bf(K,ee+c.name,c);await Promise.all([Ye(),we()])})},Ie=ce.useMemo(()=>td(D,ee),[D,ee]),ze=ce.useMemo(()=>{const m=ee.split("/").filter(Boolean);return m.map((E,x)=>({label:E,prefix:`${m.slice(0,x+1).join("/")}/`}))},[ee]),re=Ie.folders.length===0&&Ie.objects.length===0;return _.jsxs("div",{className:"app",children:[_.jsxs("header",{className:"topbar",children:[_.jsx("h1",{children:"devcloud S3 console"}),S&&_.jsxs("span",{className:"meta",children:[S.region," ยท ",S.authMode," auth"]})]}),_.jsxs("div",{className:"layout",children:[_.jsxs("nav",{className:"sidebar","aria-label":"Buckets",children:[_.jsxs("form",{className:"new-bucket",onSubmit:kt,children:[_.jsx("label",{htmlFor:"new-bucket",children:"New bucket"}),_.jsxs("div",{className:"row",children:[_.jsx("input",{id:"new-bucket",value:W,onChange:m=>xe(m.target.value),placeholder:"my-bucket",autoComplete:"off",spellCheck:!1}),_.jsx("button",{className:"button primary",disabled:U||!W.trim(),children:"Create"})]})]}),_.jsx("ul",{className:"bucket-list",children:h.map(m=>_.jsx("li",{children:_.jsxs("button",{className:m.name===K?"bucket active":"bucket","aria-current":m.name===K?"true":void 0,onClick:()=>de({bucket:m.name,prefix:""}),children:[_.jsx("span",{className:"bucket-name",children:m.name}),_.jsx("span",{className:"meta",children:m.objectCount})]})},m.name))}),h.length===0&&_.jsx("p",{className:"meta empty-note",children:"No buckets yet."})]}),_.jsxs("main",{className:"content",children:[_.jsxs("div",{"aria-live":"polite",children:[G&&_.jsx("p",{className:"banner error",role:"alert",children:G}),!G&&Oe&&_.jsx("p",{className:"banner",children:Oe})]}),!K&&_.jsx("p",{className:"placeholder",children:"Select or create a bucket."}),K&&_.jsxs(_.Fragment,{children:[_.jsxs("div",{className:"toolbar",children:[_.jsxs("nav",{className:"crumbs","aria-label":"Path",children:[_.jsx("button",{className:"link",onClick:()=>de({bucket:K,prefix:""}),children:K}),ze.map(m=>_.jsxs("span",{children:[_.jsx("span",{className:"separator",children:"/"}),_.jsx("button",{className:"link",onClick:()=>de({bucket:K,prefix:m.prefix}),children:m.label})]},m.prefix))]}),_.jsxs("div",{className:"row",children:[_.jsx("input",{ref:Ke,type:"file",multiple:!0,hidden:!0,"aria-label":"Files to upload",onChange:m=>{var E;(E=m.target.files)!=null&&E.length&&Xe(m.target.files),m.target.value=""}}),_.jsx("button",{className:"button primary",disabled:U,onClick:()=>{var m;return(m=Ke.current)==null?void 0:m.click()},children:"Upload"}),ye===K?_.jsxs(_.Fragment,{children:[_.jsx("button",{className:"button danger primary",disabled:U,onClick:()=>void tt(),children:"Confirm delete bucket"}),_.jsx("button",{className:"button",onClick:()=>Q(null),children:"Cancel"})]}):_.jsx("button",{className:"button danger",disabled:U,onClick:()=>Q(K),children:"Delete bucket"})]})]}),re?_.jsx("p",{className:"placeholder",children:"This location is empty."}):_.jsxs("table",{className:"objects",children:[_.jsx("thead",{children:_.jsxs("tr",{children:[_.jsx("th",{scope:"col",children:"Name"}),_.jsx("th",{scope:"col",className:"numeric",children:"Size"}),_.jsx("th",{scope:"col",children:"Type"}),_.jsx("th",{scope:"col",children:"Last modified"}),_.jsx("th",{scope:"col",children:_.jsx("span",{className:"sr-only",children:"Actions"})})]})}),_.jsxs("tbody",{children:[Ie.folders.map(m=>_.jsx("tr",{children:_.jsx("td",{colSpan:5,children:_.jsxs("button",{className:"link",onClick:()=>de({bucket:K,prefix:m}),children:[Ba(m),"/"]})})},m)),Ie.objects.map(m=>_.jsxs("tr",{children:[_.jsx("td",{className:"name",children:Ba(m.key)}),_.jsx("td",{className:"numeric",children:Ua(m.size)}),_.jsx("td",{className:"meta",children:m.contentType}),_.jsx("td",{className:"meta",children:nd(m.lastModified)}),_.jsx("td",{className:"actions",children:ye===m.key?_.jsxs(_.Fragment,{children:[_.jsx("button",{className:"button danger primary",disabled:U,onClick:()=>void Me(m.key),children:"Confirm"}),_.jsx("button",{className:"button",onClick:()=>Q(null),children:"Cancel"})]}):_.jsxs(_.Fragment,{children:[_.jsx("a",{className:"button",href:Ri(K,m.key),download:!0,children:"Download"}),_.jsx("button",{className:"button danger",disabled:U,onClick:()=>Q(m.key),children:"Delete"})]})})]},m.key))]})]})]})]})]})]})}const Aa=document.getElementById("root");if(!Aa)throw new Error("Console root element is missing");Yf.createRoot(Aa).render(_.jsx(ce.StrictMode,{children:_.jsx(rd,{})})); diff --git a/services/s3/assets/console/index.html b/services/s3/assets/console/index.html new file mode 100644 index 0000000..ff721f0 --- /dev/null +++ b/services/s3/assets/console/index.html @@ -0,0 +1,13 @@ + + + + + + devcloud S3 console + + + + +
+ + diff --git a/services/s3/src/aws_chunked.rs b/services/s3/src/aws_chunked.rs new file mode 100644 index 0000000..aaffc2b --- /dev/null +++ b/services/s3/src/aws_chunked.rs @@ -0,0 +1,110 @@ +//! Parser for the `aws-chunked` body framing used by `STREAMING-*` payloads: +//! `[;chunk-signature=]\r\n\r\n`, a zero-size terminal chunk, +//! then optional `name:value\r\n` trailers. + +pub struct Chunk<'a> { + pub signature: Option<&'a str>, + pub data: &'a [u8], +} + +pub struct ChunkedBody<'a> { + /// Every chunk in order, including the empty terminal chunk (it is signed too). + pub chunks: Vec>, + pub trailers: Vec<(&'a str, &'a str)>, + pub trailer_signature: Option<&'a str>, +} + +impl ChunkedBody<'_> { + pub fn payload(&self) -> Vec { + self.chunks.iter().flat_map(|c| c.data).copied().collect() + } +} + +/// Returns `None` when the framing is malformed, the closing blank line is +/// missing, or bytes follow it: none of those are covered by a signature. +pub fn parse(body: &[u8]) -> Option> { + let mut rest = body; + let mut chunks = Vec::new(); + loop { + let line = take_line(&mut rest)?; + let (size, extension) = line.split_once(';').unwrap_or((line, "")); + let size = usize::from_str_radix(size.trim(), 16).ok()?; + let signature = extension.trim().strip_prefix("chunk-signature="); + let data = rest.get(..size)?; + rest = &rest[size..]; + chunks.push(Chunk { signature, data }); + if size == 0 { + break; + } + rest = rest.strip_prefix(b"\r\n")?; + } + let mut trailers = Vec::new(); + let mut trailer_signature = None; + loop { + let line = take_line(&mut rest)?; + if line.is_empty() { + break; + } + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case("x-amz-trailer-signature") { + trailer_signature = Some(value.trim()); + } else { + trailers.push((name.trim(), value.trim())); + } + } + if !rest.is_empty() { + return None; + } + Some(ChunkedBody { + chunks, + trailers, + trailer_signature, + }) +} + +fn take_line<'a>(rest: &mut &'a [u8]) -> Option<&'a str> { + let end = rest.windows(2).position(|w| w == b"\r\n")?; + let line = std::str::from_utf8(&rest[..end]).ok()?; + *rest = &rest[end + 2..]; + Some(line) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_signed_chunks_and_trailers() { + let body = b"5;chunk-signature=aa\r\nhello\r\n1;chunk-signature=bb\r\n!\r\n0;chunk-signature=cc\r\nx-amz-checksum-crc32:AAAA\r\nx-amz-trailer-signature:dd\r\n\r\n"; + let parsed = parse(body).unwrap(); + assert_eq!(parsed.payload(), b"hello!"); + assert_eq!(parsed.chunks.len(), 3); + assert_eq!(parsed.chunks[2].signature, Some("cc")); + assert_eq!(parsed.trailers, vec![("x-amz-checksum-crc32", "AAAA")]); + assert_eq!(parsed.trailer_signature, Some("dd")); + } + + #[test] + fn parses_unsigned_body_without_trailers() { + let parsed = parse(b"3\r\nabc\r\n0\r\n\r\n").unwrap(); + assert_eq!(parsed.payload(), b"abc"); + assert!(parsed.trailers.is_empty()); + } + + #[test] + fn rejects_truncated_or_invalid_framing() { + for body in [ + &b"5\r\nhel"[..], + b"5\r\nhello\r\n", + b"zz\r\nhello\r\n0\r\n\r\n", + b"5\r\nhelloXX0\r\n\r\n", + b"ffffffffffffffff\r\nx\r\n", + b"plain body", + b"3\r\nabc\r\n0\r\n", + b"3\r\nabc\r\n0\r\nx-amz-checksum-crc32:AAAA\r\n", + b"3\r\nabc\r\n0\r\n\r\ntrailing", + ] { + assert!(parse(body).is_none(), "{:?}", String::from_utf8_lossy(body)); + } + } +} diff --git a/services/s3/src/console.rs b/services/s3/src/console.rs new file mode 100644 index 0000000..bc5762c --- /dev/null +++ b/services/s3/src/console.rs @@ -0,0 +1,350 @@ +//! Web console for the standalone image: an embedded single-page app plus a small +//! JSON API on its own port. Reads use the introspection API and mutations go +//! through the S3 provider protocol in-process, never directly to storage. +//! +//! Browser-origin protection: every mutation is PUT or DELETE and `OPTIONS` is +//! refused, so a cross-site page cannot get past the CORS preflight; the Host +//! allowlist stops DNS rebinding. Strict auth mode adds HTTP Basic, using the +//! configured access key and secret. + +use std::sync::{Arc, Mutex}; + +use include_dir::{include_dir, Dir}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::http::{ + constant_time_eq, percent_decode, read_request, route, tag_text_in, write_response, AuthConfig, + Request, Response, +}; +use crate::percent::aws_percent_encode; +use crate::store::FileBucketStore; +use crate::validation::valid_bucket_name; + +static UI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/assets/console"); + +const SPA_CSP: &str = + "default-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; +const API_CSP: &str = + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox"; + +#[derive(Debug, Clone)] +pub struct ConsoleConfig { + pub auth: AuthConfig, + /// Extra Host names beyond loopback, IPs, and single-label names; `*` allows any. + pub allowed_hosts: Vec, +} + +/// Runs the console accept loop until `shutdown` resolves. +pub async fn serve( + listener: TcpListener, + store: Arc>, + config: ConsoleConfig, + shutdown: impl std::future::Future, +) -> std::io::Result<()> { + tokio::pin!(shutdown); + let config = Arc::new(config); + loop { + tokio::select! { + _ = &mut shutdown => return Ok(()), + accepted = listener.accept() => { + let (stream, _) = accepted?; + let store = Arc::clone(&store); + let config = Arc::clone(&config); + tokio::spawn(async move { + let _ = handle_conn(stream, store, config).await; + }); + } + } + } +} + +async fn handle_conn( + mut stream: TcpStream, + store: Arc>, + config: Arc, +) -> std::io::Result<()> { + let request = match read_request(&mut stream).await { + Ok(Some(req)) => req, + _ => return Ok(()), + }; + let response = { + let store = store.lock().unwrap(); + dispatch(&store, &request, &config) + }; + write_response(&mut stream, response).await +} + +pub fn dispatch(store: &FileBucketStore, req: &Request, config: &ConsoleConfig) -> Response { + let mut response = dispatch_unsecured(store, req, config); + let policy = if req.path.starts_with("/api/") { + API_CSP + } else { + SPA_CSP + }; + response + .headers + .insert("Content-Security-Policy".to_string(), policy.to_string()); + response + .headers + .insert("X-Content-Type-Options".to_string(), "nosniff".to_string()); + response +} + +fn dispatch_unsecured(store: &FileBucketStore, req: &Request, config: &ConsoleConfig) -> Response { + if !host_allowed(req.header("host"), &config.allowed_hosts) { + return json_error( + 403, + "Host denied. Add the hostname to DEVCLOUD_S3_CONSOLE_ALLOWED_HOSTS.", + ); + } + if !authorized(req, &config.auth) { + let mut response = json_error(401, "Unauthorized"); + response.headers.insert( + "WWW-Authenticate".to_string(), + "Basic realm=\"devcloud-s3\"".to_string(), + ); + return response; + } + match req.path.strip_prefix("/api/") { + Some(path) => handle_api(store, req, config, path), + None => serve_asset(req), + } +} + +fn authorized(req: &Request, auth: &AuthConfig) -> bool { + if !auth.auth_mode.eq_ignore_ascii_case("strict") { + return true; + } + let credentials = req + .header("authorization") + .strip_prefix("Basic ") + .and_then(crate::base64::std_decode) + .and_then(|decoded| String::from_utf8(decoded).ok()); + let Some((user, password)) = credentials.as_deref().and_then(|c| c.split_once(':')) else { + return false; + }; + let user_ok = constant_time_eq(user.as_bytes(), auth.access_key_id.as_bytes()); + let password_ok = constant_time_eq(password.as_bytes(), auth.secret_access_key.as_bytes()); + user_ok & password_ok +} + +fn handle_api( + store: &FileBucketStore, + req: &Request, + config: &ConsoleConfig, + path: &str, +) -> Response { + if path == "info" { + return match req.method.as_str() { + "GET" => json_response( + 200, + &serde_json::json!({ + "authMode": if config.auth.auth_mode.eq_ignore_ascii_case("strict") { + "strict" + } else { + "relaxed" + }, + "region": if config.auth.region.is_empty() { + "us-east-1" + } else { + config.auth.region.as_str() + }, + }), + ), + _ => method_not_allowed("GET"), + }; + } + if path == "buckets" { + return match req.method.as_str() { + "GET" => introspect(store, "/_introspect/buckets"), + _ => method_not_allowed("GET"), + }; + } + let Some(rest) = path.strip_prefix("buckets/") else { + return json_error(404, "not found"); + }; + let (raw_bucket, suffix) = rest.split_once('/').unwrap_or((rest, "")); + let bucket = percent_decode(raw_bucket); + if !valid_bucket_name(&bucket) { + return json_error(400, "Bucket names must be valid S3 bucket names."); + } + if suffix.is_empty() && !rest.ends_with('/') { + return match req.method.as_str() { + "PUT" | "DELETE" => provider(store, &req.method, &format!("/{bucket}"), req), + _ => method_not_allowed("PUT, DELETE"), + }; + } + if suffix == "objects" { + if req.method != "GET" { + return method_not_allowed("GET"); + } + let prefix = req.query.get("prefix").map(String::as_str).unwrap_or(""); + return introspect( + store, + &format!( + "/_introspect/buckets/{bucket}/objects?prefix={}", + aws_percent_encode(prefix, "") + ), + ); + } + if suffix != "object" { + return json_error(404, "not found"); + } + // The key travels in the query: browsers resolve `.` and `..` path segments + // (even as `%2e`), which would retarget the request at a different object. + let Some(key) = req.query.get("key").filter(|k| !k.is_empty()) else { + return json_error(400, "The key parameter is required."); + }; + let target = format!("/{bucket}/{}", aws_percent_encode(key, "/")); + match req.method.as_str() { + "GET" => download(store, &target, key, req), + "PUT" | "DELETE" => provider(store, &req.method, &target, req), + _ => method_not_allowed("GET, PUT, DELETE"), + } +} + +fn introspect(store: &FileBucketStore, target: &str) -> Response { + crate::introspect::handle_introspect(store, &Request::new("GET", target, Vec::new())) +} + +/// Forwards to the S3 router with relaxed auth: the console has already +/// authorized the caller, and it holds no SigV4 signature to present. +fn provider(store: &FileBucketStore, method: &str, target: &str, req: &Request) -> Response { + let mut forwarded = Request::new(method, target, req.body.clone()); + if !req.header("content-type").is_empty() { + forwarded.headers.insert( + "content-type".to_string(), + req.header("content-type").to_string(), + ); + } + json_errors(route(store, &forwarded)) +} + +fn download(store: &FileBucketStore, target: &str, key: &str, req: &Request) -> Response { + let mut response = provider(store, "GET", target, req); + if response.status == 200 { + // Always an attachment: a stored HTML object must never render on the + // console's origin. + let filename = key.rsplit('/').next().unwrap_or(key); + response.headers.insert( + "Content-Disposition".to_string(), + format!( + "attachment; filename*=UTF-8''{}", + aws_percent_encode(filename, "") + ), + ); + } + response +} + +fn json_errors(response: Response) -> Response { + if response.status < 400 { + return response; + } + let body = String::from_utf8_lossy(&response.body); + let code = tag_text_in(&body, "Code").unwrap_or_default(); + let message = tag_text_in(&body, "Message").unwrap_or_else(|| "request failed".to_string()); + json_response( + response.status, + &serde_json::json!({ "error": message, "code": code }), + ) +} + +fn serve_asset(req: &Request) -> Response { + if req.method != "GET" { + return method_not_allowed("GET"); + } + let name = if req.path == "/" { + "index.html" + } else { + req.path.trim_start_matches('/') + }; + let Some(file) = UI.get_file(name) else { + return json_error(404, "not found"); + }; + let content_type = match name.rsplit('.').next().unwrap_or("") { + "html" => "text/html; charset=utf-8", + "js" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + }; + let mut response = Response::with_body(200, file.contents().to_vec()); + response + .headers + .insert("Content-Type".to_string(), content_type.to_string()); + response +} + +fn method_not_allowed(allow: &str) -> Response { + let mut response = json_error(405, "method not allowed"); + response + .headers + .insert("Allow".to_string(), allow.to_string()); + response +} + +fn json_response(status: u16, value: &serde_json::Value) -> Response { + let mut response = Response::with_body(status, value.to_string().into_bytes()); + response + .headers + .insert("Content-Type".to_string(), "application/json".to_string()); + response +} + +fn json_error(status: u16, message: &str) -> Response { + json_response(status, &serde_json::json!({ "error": message })) +} + +/// Same policy as the mailbox console: loopback, IP literals, `.localhost`, +/// `host.docker.internal`, and single-label names pass; a dotted public-looking +/// name must be listed, which defeats DNS rebinding. +pub fn host_allowed(value: &str, extra: &[String]) -> bool { + if value.is_empty() + || value.chars().any(|c| c.is_whitespace() || c.is_control()) + || value.contains(['/', '\\', '@', '?', '#']) + { + return false; + } + let host = if let Some(rest) = value.strip_prefix('[') { + let Some((ip, suffix)) = rest.split_once(']') else { + return false; + }; + if ip.parse::().is_err() { + return false; + } + if !suffix.is_empty() && !suffix.strip_prefix(':').is_some_and(valid_port) { + return false; + } + ip + } else if value.parse::().is_ok() { + value + } else if let Some((host, port)) = value.rsplit_once(':') { + if host.contains(':') || !valid_port(port) { + return false; + } + host + } else { + value + }; + if host.is_empty() + || !host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-_:".contains(&b)) + { + return false; + } + let host = host.to_ascii_lowercase(); + extra + .iter() + .any(|s| s == "*" || s.eq_ignore_ascii_case(&host)) + || host.parse::().is_ok() + || host == "localhost" + || host.ends_with(".localhost") + || host == "host.docker.internal" + || !host.contains('.') +} + +fn valid_port(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) && value.parse::().is_ok() +} diff --git a/services/s3/src/cors.rs b/services/s3/src/cors.rs new file mode 100644 index 0000000..e4e8114 --- /dev/null +++ b/services/s3/src/cors.rs @@ -0,0 +1,248 @@ +//! Bucket CORS configuration: XML parsing/serialization and AWS-style rule +//! matching. Routing and response assembly live in `http.rs`. + +use serde::{Deserialize, Serialize}; + +use crate::xml::{encode, Element}; + +const XMLNS: &str = "http://s3.amazonaws.com/doc/2006-03-01/"; +const MAX_RULES: usize = 100; +const ALLOWED_METHODS: [&str; 5] = ["GET", "PUT", "POST", "DELETE", "HEAD"]; +const DEFAULT_RULE_MAX_AGE_SECONDS: u32 = 3600; +/// Response headers browser SDKs read, none of which are CORS-safelisted. +const DEFAULT_RULE_EXPOSE_HEADERS: [&str; 5] = [ + "ETag", + "Content-Range", + "Accept-Ranges", + "x-amz-version-id", + "x-amz-delete-marker", +]; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct CorsRule { + pub id: String, + pub allowed_origins: Vec, + pub allowed_methods: Vec, + pub allowed_headers: Vec, + pub expose_headers: Vec, + pub max_age_seconds: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct CorsConfiguration { + pub rules: Vec, +} + +/// Builds the server-wide rule used by buckets without their own configuration. +/// `None` when no origin is configured, leaving only bucket rules in effect. +pub fn default_rule(origins: &[String]) -> Option { + if origins.is_empty() { + return None; + } + Some(CorsRule { + id: String::new(), + allowed_origins: origins.to_vec(), + allowed_methods: ALLOWED_METHODS.iter().map(|m| m.to_string()).collect(), + allowed_headers: vec!["*".to_string()], + expose_headers: DEFAULT_RULE_EXPOSE_HEADERS + .iter() + .map(|h| h.to_string()) + .collect(), + max_age_seconds: Some(DEFAULT_RULE_MAX_AGE_SECONDS), + }) +} + +/// Parses and validates a `` document. +pub fn parse_configuration(body: &[u8]) -> Result { + const MALFORMED: &str = "the XML is not well-formed or does not match the CORS schema"; + let xml = std::str::from_utf8(body).map_err(|_| MALFORMED)?; + if !xml.contains(" Some(value.trim().parse::().map_err(|_| MALFORMED)?), + None => None, + }; + rules.push(CorsRule { + id: xml_values(block, "ID") + .into_iter() + .next() + .unwrap_or_default(), + allowed_origins: xml_values(block, "AllowedOrigin"), + allowed_methods: xml_values(block, "AllowedMethod"), + allowed_headers: xml_values(block, "AllowedHeader"), + expose_headers: xml_values(block, "ExposeHeader"), + max_age_seconds, + }); + } + validate(&rules)?; + Ok(CorsConfiguration { rules }) +} + +fn validate(rules: &[CorsRule]) -> Result<(), &'static str> { + if rules.is_empty() || rules.len() > MAX_RULES { + return Err("a CORS configuration must have between 1 and 100 rules"); + } + for rule in rules { + if rule.allowed_origins.is_empty() || rule.allowed_methods.is_empty() { + return Err("each CORSRule needs an AllowedOrigin and an AllowedMethod"); + } + if !rule + .allowed_methods + .iter() + .all(|m| ALLOWED_METHODS.contains(&m.as_str())) + { + return Err("AllowedMethod must be one of GET, PUT, POST, DELETE, HEAD"); + } + if rule + .allowed_origins + .iter() + .chain(&rule.allowed_headers) + .any(|v| v.matches('*').count() > 1) + { + return Err("AllowedOrigin and AllowedHeader may contain one wildcard"); + } + if rule.expose_headers.iter().any(|v| v.contains('*')) { + return Err("ExposeHeader may not contain a wildcard"); + } + // These values are written verbatim into response headers. + if !rule.expose_headers.iter().all(|v| is_header_name(v)) { + return Err("ExposeHeader must be a valid HTTP header name"); + } + } + Ok(()) +} + +fn is_header_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'+-.^_`|~".contains(&b)) +} + +pub fn configuration_xml(config: &CorsConfiguration) -> Vec { + let mut root = Element::new("CORSConfiguration").attr("xmlns", XMLNS); + for rule in &config.rules { + let mut element = Element::new("CORSRule"); + if !rule.id.is_empty() { + element = element.text_child("ID", &rule.id); + } + for (tag, values) in [ + ("AllowedOrigin", &rule.allowed_origins), + ("AllowedMethod", &rule.allowed_methods), + ("AllowedHeader", &rule.allowed_headers), + ("ExposeHeader", &rule.expose_headers), + ] { + for value in values { + element = element.text_child(tag, value); + } + } + if let Some(max_age) = rule.max_age_seconds { + element = element.text_child("MaxAgeSeconds", &max_age.to_string()); + } + root = root.child(element); + } + encode(&root) +} + +/// Returns the first rule allowing this origin, method, and every requested +/// header. Header names compare case-insensitively; origins are case-sensitive. +pub fn find_rule<'a>( + rules: &'a [CorsRule], + origin: &str, + method: &str, + request_headers: &[&str], +) -> Option<&'a CorsRule> { + rules.iter().find(|rule| { + rule.allowed_methods.iter().any(|m| m == method) + && rule + .allowed_origins + .iter() + .any(|pattern| wildcard_match(pattern, origin)) + && request_headers.iter().all(|header| { + let header = header.to_ascii_lowercase(); + rule.allowed_headers + .iter() + .any(|pattern| wildcard_match(&pattern.to_ascii_lowercase(), &header)) + }) + }) +} + +/// The `Access-Control-*` headers for an allowed request. Credentials are only +/// advertised for a specific origin: browsers reject them alongside `*`. +pub fn response_headers(rule: &CorsRule, origin: &str) -> Vec<(String, String)> { + let any_origin = rule.allowed_origins.iter().any(|o| o == "*"); + let mut headers = vec![ + ( + "Access-Control-Allow-Origin".to_string(), + if any_origin { "*" } else { origin }.to_string(), + ), + ( + "Access-Control-Allow-Methods".to_string(), + rule.allowed_methods.join(", "), + ), + ]; + if !any_origin { + headers.push(( + "Access-Control-Allow-Credentials".to_string(), + "true".to_string(), + )); + } + if !rule.expose_headers.is_empty() { + headers.push(( + "Access-Control-Expose-Headers".to_string(), + rule.expose_headers.join(", "), + )); + } + if let Some(max_age) = rule.max_age_seconds { + headers.push(("Access-Control-Max-Age".to_string(), max_age.to_string())); + } + headers +} + +fn wildcard_match(pattern: &str, value: &str) -> bool { + match pattern.split_once('*') { + Some((prefix, suffix)) => { + value.len() >= prefix.len() + suffix.len() + && value.starts_with(prefix) + && value.ends_with(suffix) + } + None => pattern == value, + } +} + +fn xml_blocks<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> { + let open = format!("<{tag}>"); + let close = format!(""); + let mut out = Vec::new(); + let mut rest = xml; + while let Some(start) = rest.find(&open) { + let inner = &rest[start + open.len()..]; + let Some(end) = inner.find(&close) else { + break; + }; + out.push(&inner[..end]); + rest = &inner[end + close.len()..]; + } + out +} + +fn xml_values(xml: &str, tag: &str) -> Vec { + xml_blocks(xml, tag) + .into_iter() + .map(|value| xml_unescape(value.trim())) + .collect() +} + +fn xml_unescape(value: &str) -> String { + value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} diff --git a/services/s3/src/http.rs b/services/s3/src/http.rs index 36344fe..ad6a6cc 100644 --- a/services/s3/src/http.rs +++ b/services/s3/src/http.rs @@ -2,7 +2,8 @@ //! //! Scope: service listing, bucket CRUD, current-object PUT/GET/HEAD/DELETE, //! object listing, selected bucket/object sub-resources, multipart upload, and -//! `If-None-Match` (GET/HEAD 304, conditional PUT/Copy/CompleteMultipartUpload). +//! `If-None-Match` (GET/HEAD 304, conditional PUT/Copy/CompleteMultipartUpload), +//! bucket CORS, and `aws-chunked` streaming uploads. use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; @@ -10,9 +11,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; +use crate::aws_chunked; +use crate::cors::{self, CorsRule}; use crate::model::{ AnalyticsConfiguration, AnalyticsDataExport, AnalyticsDestination, AnalyticsFilter, AnalyticsS3BucketDestination, DefaultRetention, InventoryConfiguration, InventoryDestination, @@ -47,6 +50,12 @@ const MAX_BODY_BYTES: usize = 128 * 1024 * 1024; const MAX_POLICY_BYTES: usize = 1024 * 1024; const SIGV4_ALGORITHM: &str = "AWS4-HMAC-SHA256"; const SIGV4_SERVICE: &str = "s3"; +const SIGV4_MAX_CLOCK_SKEW_SECONDS: i64 = 900; +const SIGV4_CHUNK_ALGORITHM: &str = "AWS4-HMAC-SHA256-PAYLOAD"; +const SIGV4_TRAILER_ALGORITHM: &str = "AWS4-HMAC-SHA256-TRAILER"; +const STREAMING_SIGNED: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"; +const STREAMING_SIGNED_TRAILER: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER"; +const STREAMING_UNSIGNED_TRAILER: &str = "STREAMING-UNSIGNED-PAYLOAD-TRAILER"; const DASHBOARD_EVENT_PREFIX: &str = "DEVCLOUD_EVENT "; #[derive(Debug, Clone)] @@ -140,15 +149,29 @@ pub async fn serve( serve_with_auth(listener, store, AuthConfig::default(), shutdown).await } -/// Runs the accept loop with explicit SigV4/auth settings. +/// Runs the accept loop with explicit SigV4/auth settings and no server-wide +/// CORS rule; bucket CORS configurations still apply. pub async fn serve_with_auth( listener: TcpListener, store: Arc>, auth: AuthConfig, shutdown: impl std::future::Future, +) -> std::io::Result<()> { + serve_with_cors(listener, store, auth, None, shutdown).await +} + +/// Runs the accept loop. `default_cors` applies to buckets without their own +/// CORS configuration and to the service root. +pub async fn serve_with_cors( + listener: TcpListener, + store: Arc>, + auth: AuthConfig, + default_cors: Option, + shutdown: impl std::future::Future, ) -> std::io::Result<()> { tokio::pin!(shutdown); let auth = Arc::new(auth); + let default_cors = Arc::new(default_cors); loop { tokio::select! { _ = &mut shutdown => return Ok(()), @@ -156,8 +179,9 @@ pub async fn serve_with_auth( let (stream, _) = accepted?; let store = Arc::clone(&store); let auth = Arc::clone(&auth); + let default_cors = Arc::clone(&default_cors); tokio::spawn(async move { - let _ = handle_conn(stream, store, auth).await; + let _ = handle_conn(stream, store, auth, default_cors).await; }); } } @@ -168,6 +192,7 @@ async fn handle_conn( mut stream: TcpStream, store: Arc>, auth: Arc, + default_cors: Arc>, ) -> std::io::Result<()> { let request = match read_request(&mut stream).await { Ok(Some(req)) => req, @@ -175,12 +200,14 @@ async fn handle_conn( }; let response = { let store = store.lock().unwrap(); - route_with_auth(&store, &request, &auth) + route_with_cors(&store, &request, &auth, default_cors.as_ref().as_ref()) }; write_response(&mut stream, response).await } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +pub(crate) async fn read_request( + stream: &mut S, +) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { @@ -208,19 +235,63 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = BTreeMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + // Repeated headers fold with a comma, which SigV4 canonicalization also expects. + headers + .entry(k.trim().to_ascii_lowercase()) + .and_modify(|existing: &mut String| { + existing.push(','); + existing.push_str(v.trim()); + }) + .or_insert_with(|| v.trim().to_string()); } } - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + let received = buf[header_end + 4..].to_vec(); + let is_chunked = headers + .get("transfer-encoding") + .is_some_and(|v| v.to_ascii_lowercase().contains("chunked")); + let body = if is_chunked { + read_http_chunked_body(stream, received).await? + } else { + read_sized_body(stream, received, headers.get("content-length")).await? + }; + let Some(body) = body else { + return Ok(None); + }; + + Ok(Some(Request { + method, + path, + query, + headers, + body, + })) +} + +/// A repeated `Content-Length` is only valid when every value agrees (RFC 9110). +/// Anything else is refused rather than read as an empty body, which would +/// overwrite an object with zero bytes. +fn parse_content_length(value: Option<&String>) -> Option { + let Some(value) = value else { + return Some(0); + }; + let mut lengths = value.split(',').map(|v| v.trim().parse::().ok()); + let first = lengths.next()??; + lengths.all(|v| v == Some(first)).then_some(first) +} + +async fn read_sized_body( + stream: &mut S, + mut body: Vec, + content_length: Option<&String>, +) -> std::io::Result>> { + let Some(content_length) = parse_content_length(content_length) else { + return Ok(None); + }; if content_length > MAX_BODY_BYTES { return Ok(None); } - - let mut body = buf[header_end + 4..].to_vec(); + let mut tmp = [0u8; 4096]; while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { @@ -229,14 +300,74 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result body.extend_from_slice(&tmp[..n]); } body.truncate(content_length); + Ok(Some(body)) +} - Ok(Some(Request { - method, - path, - query, - headers, - body, - })) +/// Reads more bytes into `raw`, returning false at end of stream. +async fn fill(stream: &mut S, raw: &mut Vec) -> std::io::Result { + let mut tmp = [0u8; 4096]; + let n = stream.read(&mut tmp).await?; + raw.extend_from_slice(&tmp[..n]); + Ok(n > 0) +} + +/// Removes and returns the next CRLF-terminated line; `None` at end of stream or +/// when a line exceeds the header limit. +async fn read_chunk_line( + stream: &mut S, + raw: &mut Vec, +) -> std::io::Result> { + loop { + if let Some(end) = find_subslice(raw, b"\r\n") { + let line = String::from_utf8_lossy(&raw[..end]).into_owned(); + raw.drain(..end + 2); + return Ok(Some(line)); + } + if raw.len() > MAX_HEADER_BYTES || !fill(stream, raw).await? { + return Ok(None); + } + } +} + +/// Decodes `Transfer-Encoding: chunked`. An `aws-chunked` payload sent this way +/// still carries its own framing, which routing decodes afterwards. +async fn read_http_chunked_body( + stream: &mut S, + mut raw: Vec, +) -> std::io::Result>> { + let mut body = Vec::new(); + loop { + let Some(line) = read_chunk_line(stream, &mut raw).await? else { + return Ok(None); + }; + let size = line.split(';').next().unwrap_or("").trim(); + let Ok(size) = usize::from_str_radix(size, 16) else { + return Ok(None); + }; + if size == 0 { + break; + } + if size > MAX_BODY_BYTES - body.len() { + return Ok(None); + } + while raw.len() < size + 2 { + if !fill(stream, &mut raw).await? { + return Ok(None); + } + } + if &raw[size..size + 2] != b"\r\n" { + return Ok(None); + } + body.extend_from_slice(&raw[..size]); + raw.drain(..size + 2); + } + loop { + match read_chunk_line(stream, &mut raw).await? { + Some(line) if line.is_empty() => return Ok(Some(body)), + Some(_) => {} + None => return Ok(None), + } + } } /// Routes a parsed request to the S3 store. @@ -246,9 +377,137 @@ pub fn route(store: &FileBucketStore, req: &Request) -> Response { /// Routes with SigV4 verification when `auth.auth_mode == "strict"`. pub fn route_with_auth(store: &FileBucketStore, req: &Request, auth: &AuthConfig) -> Response { - if let Err(err) = verify_signature(req, auth) { - return xml_error(err.status, err.code, err.code); + route_with_cors(store, req, auth, None) +} + +/// Routes with SigV4 verification and CORS handling. Browsers never attach +/// credentials to a preflight, so `OPTIONS` is answered before signature checks. +pub fn route_with_cors( + store: &FileBucketStore, + req: &Request, + auth: &AuthConfig, + default_cors: Option<&CorsRule>, +) -> Response { + if crate::introspect::is_introspect_path(&req.path) { + return route_verified(store, req, auth); + } + if req.method == "OPTIONS" { + return cors_preflight(store, req, default_cors); + } + let origin = req.header("origin"); + if origin.is_empty() { + return route_verified(store, req, auth); } + // Read before routing: DeleteBucket removes the configuration it is answered under. + let rules = cors_rules(store, &req.path, default_cors); + let mut response = route_verified(store, req, auth); + if !rules.is_empty() { + response + .headers + .insert("Vary".to_string(), CORS_VARY.to_string()); + } + if let Some(rule) = cors::find_rule(&rules, origin, &req.method, &[]) { + response + .headers + .extend(cors::response_headers(rule, origin)); + } + response +} + +const CORS_VARY: &str = "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"; + +fn cors_rules( + store: &FileBucketStore, + path: &str, + default_cors: Option<&CorsRule>, +) -> Vec { + let bucket_config = parse_path_style(path) + .and_then(|(bucket, _)| store.get_bucket_cors(&bucket).ok().flatten()); + match bucket_config { + Some(config) => config.rules, + None => default_cors.cloned().into_iter().collect(), + } +} + +fn cors_preflight( + store: &FileBucketStore, + req: &Request, + default_cors: Option<&CorsRule>, +) -> Response { + let origin = req.header("origin"); + if origin.is_empty() { + return xml_error( + 400, + "BadRequest", + "Insufficient information. Origin request header needed.", + ); + } + let method = req.header("access-control-request-method"); + if method.is_empty() { + return xml_error( + 400, + "BadRequest", + "Invalid Access-Control-Request-Method: null", + ); + } + let rules = cors_rules(store, &req.path, default_cors); + if rules.is_empty() { + let bucket_missing = parse_path_style(&req.path) + .is_some_and(|(bucket, _)| !matches!(store.get_bucket(&bucket), Ok(Some(_)))); + return if bucket_missing { + xml_error(404, "NoSuchBucket", "bucket does not exist") + } else { + xml_error(403, "CORSResponse", "CORS is not enabled for this bucket.") + }; + } + let requested_headers: Vec<&str> = req + .header("access-control-request-headers") + .split(',') + .map(str::trim) + .filter(|h| !h.is_empty()) + .collect(); + let Some(rule) = cors::find_rule(&rules, origin, method, &requested_headers) else { + return xml_error( + 403, + "CORSResponse", + "This CORS request is not allowed. This is usually because the evaluation of \ + Origin, request method / Access-Control-Request-Method or \ + Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + ); + }; + let mut response = Response::empty(200); + response + .headers + .extend(cors::response_headers(rule, origin)); + response + .headers + .insert("Vary".to_string(), CORS_VARY.to_string()); + if !requested_headers.is_empty() { + response.headers.insert( + "Access-Control-Allow-Headers".to_string(), + requested_headers.join(", ").to_ascii_lowercase(), + ); + } + response +} + +fn route_verified(store: &FileBucketStore, req: &Request, auth: &AuthConfig) -> Response { + let chunk_signing = match verify_signature(req, auth) { + Ok(chunk_signing) => chunk_signing, + Err(err) => return xml_error(err.status, err.code, err.code), + }; + // Decoded in every auth mode: otherwise the chunk framing would be stored as + // part of the object. + if req.header("x-amz-content-sha256").starts_with("STREAMING-") { + return match decode_streaming_request(req, chunk_signing.as_ref()) { + Ok(decoded) => dispatch(store, &decoded), + Err(err) => xml_error(err.status, err.code, err.code), + }; + } + dispatch(store, req) +} + +fn dispatch(store: &FileBucketStore, req: &Request) -> Response { if req.path == "/" { return handle_service(store, req); } @@ -295,6 +554,9 @@ fn handle_bucket(store: &FileBucketStore, req: &Request, bucket: &str) -> Respon if req.query.contains_key("policy") { return put_bucket_policy(store, req, bucket); } + if req.query.contains_key("cors") { + return put_bucket_cors(store, req, bucket); + } if req.query.contains_key("lifecycle") { return put_bucket_lifecycle(store, req, bucket); } @@ -352,6 +614,9 @@ fn handle_bucket(store: &FileBucketStore, req: &Request, bucket: &str) -> Respon if req.query.contains_key("policy") { return get_bucket_policy(store, bucket); } + if req.query.contains_key("cors") { + return get_bucket_cors(store, bucket); + } if req.query.contains_key("lifecycle") { return get_bucket_lifecycle(store, bucket); } @@ -383,6 +648,9 @@ fn handle_bucket(store: &FileBucketStore, req: &Request, bucket: &str) -> Respon if req.query.contains_key("policy") { return delete_bucket_policy(store, bucket); } + if req.query.contains_key("cors") { + return delete_bucket_cors(store, bucket); + } if req.query.contains_key("lifecycle") { return delete_bucket_lifecycle(store, bucket); } @@ -695,6 +963,55 @@ fn delete_bucket_policy(store: &FileBucketStore, bucket: &str) -> Response { } } +fn put_bucket_cors(store: &FileBucketStore, req: &Request, bucket: &str) -> Response { + if req.body.len() > MAX_POLICY_BYTES { + return xml_error(400, "MalformedXML", "CORS configuration is too large"); + } + let config = match cors::parse_configuration(&req.body) { + Ok(config) => config, + Err(message) => return xml_error(400, "MalformedXML", message), + }; + match store.put_bucket_cors(bucket, &config) { + Ok(()) => Response::empty(200), + Err(StoreError::BucketNotExist) => xml_error(404, "NoSuchBucket", "bucket does not exist"), + Err(StoreError::InvalidBucketName) => { + xml_error(400, "InvalidBucketName", "invalid bucket name") + } + Err(_) => xml_error(500, "InternalError", "internal error"), + } +} + +fn get_bucket_cors(store: &FileBucketStore, bucket: &str) -> Response { + match store.get_bucket(bucket) { + Ok(Some(_)) => {} + Ok(None) => return xml_error(404, "NoSuchBucket", "bucket does not exist"), + Err(StoreError::InvalidBucketName) => { + return xml_error(400, "InvalidBucketName", "invalid bucket name") + } + Err(_) => return xml_error(500, "InternalError", "internal error"), + } + match store.get_bucket_cors(bucket) { + Ok(Some(config)) => Response::xml(200, cors::configuration_xml(&config)), + Ok(None) => xml_error( + 404, + "NoSuchCORSConfiguration", + "the CORS configuration does not exist", + ), + Err(_) => xml_error(500, "InternalError", "internal error"), + } +} + +fn delete_bucket_cors(store: &FileBucketStore, bucket: &str) -> Response { + match store.delete_bucket_cors(bucket) { + Ok(()) => Response::empty(204), + Err(StoreError::BucketNotExist) => xml_error(404, "NoSuchBucket", "bucket does not exist"), + Err(StoreError::InvalidBucketName) => { + xml_error(400, "InvalidBucketName", "invalid bucket name") + } + Err(_) => xml_error(500, "InternalError", "internal error"), + } +} + fn put_bucket_lifecycle(store: &FileBucketStore, req: &Request, bucket: &str) -> Response { let lifecycle = match parse_lifecycle_configuration(&req.body) { Ok(config) => config, @@ -1882,9 +2199,21 @@ struct SignatureError { status: u16, } -fn verify_signature(req: &Request, auth: &AuthConfig) -> Result<(), SignatureError> { +/// What `aws-chunked` verification needs from an already verified seed signature. +struct ChunkSigning { + signing_key: Vec, + amz_date: String, + scope: String, + seed_signature: String, + has_trailer: bool, +} + +fn verify_signature( + req: &Request, + auth: &AuthConfig, +) -> Result, SignatureError> { if !auth.auth_mode.eq_ignore_ascii_case("strict") { - return Ok(()); + return Ok(None); } let has_presign = req .query @@ -1899,7 +2228,7 @@ fn verify_signature(req: &Request, auth: &AuthConfig) -> Result<(), SignatureErr }); } if has_presign { - verify_presigned_url(req, auth) + verify_presigned_url(req, auth).map(|()| None) } else { verify_authorization_header(req, auth) } @@ -1942,24 +2271,18 @@ fn verify_presigned_url(req: &Request, auth: &AuthConfig) -> Result<(), Signatur code: "AccessDenied", status: 403, })?; - if parse_sigv4_time(amz_date).is_none() { + let signed_at = parse_sigv4_time(amz_date).ok_or(SignatureError { + code: "AccessDenied", + status: 403, + })?; + let now = unix_now(); + if now > signed_at + expires || signed_at > now + SIGV4_MAX_CLOCK_SKEW_SECONDS { return Err(SignatureError { code: "AccessDenied", status: 403, }); } - if let Some(signed_at) = parse_sigv4_time(amz_date) { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if now > signed_at + expires { - return Err(SignatureError { - code: "AccessDenied", - status: 403, - }); - } - } + verify_scope_date(amz_date, date_stamp)?; let signed_headers = req .query .get("X-Amz-SignedHeaders") @@ -1971,16 +2294,35 @@ fn verify_presigned_url(req: &Request, auth: &AuthConfig) -> Result<(), Signatur status: 400, }); } + verify_signed_header_coverage(req, signed_headers)?; + let payload_hash = match req.header("x-amz-content-sha256") { + "" => "UNSIGNED-PAYLOAD", + streaming if streaming.starts_with("STREAMING-") => { + return Err(SignatureError { + code: "NotImplemented", + status: 501, + }) + } + hash => { + verify_payload_hash(req, hash)?; + hash + } + }; let expected = signature_for_request( req, auth, date_stamp, region, signed_headers, - "UNSIGNED-PAYLOAD", + payload_hash, "X-Amz-Signature", ); - if req.query.get("X-Amz-Signature").map(String::as_str) != Some(expected.as_str()) { + let signature = req + .query + .get("X-Amz-Signature") + .map(String::as_str) + .unwrap_or(""); + if !constant_time_eq(signature.as_bytes(), expected.as_bytes()) { return Err(SignatureError { code: "SignatureDoesNotMatch", status: 403, @@ -1989,7 +2331,10 @@ fn verify_presigned_url(req: &Request, auth: &AuthConfig) -> Result<(), Signatur Ok(()) } -fn verify_authorization_header(req: &Request, auth: &AuthConfig) -> Result<(), SignatureError> { +fn verify_authorization_header( + req: &Request, + auth: &AuthConfig, +) -> Result, SignatureError> { let authorization = req.header("authorization"); let Some(rest) = authorization.strip_prefix(&format!("{SIGV4_ALGORITHM} ")) else { return Err(SignatureError { @@ -2021,12 +2366,25 @@ fn verify_authorization_header(req: &Request, auth: &AuthConfig) -> Result<(), S status: 403, }); } - if req.header("x-amz-date").is_empty() { + let amz_date = req.header("x-amz-date"); + if amz_date.is_empty() { return Err(SignatureError { code: "AuthorizationHeaderMalformed", status: 400, }); } + let signed_at = parse_sigv4_time(amz_date).ok_or(SignatureError { + code: "AccessDenied", + status: 403, + })?; + if (unix_now() - signed_at).abs() > SIGV4_MAX_CLOCK_SKEW_SECONDS { + return Err(SignatureError { + code: "RequestTimeTooSkewed", + status: 403, + }); + } + verify_scope_date(amz_date, date_stamp)?; + verify_signed_header_coverage(req, signed_headers)?; let payload_hash = if req.header("x-amz-content-sha256").is_empty() { "UNSIGNED-PAYLOAD".to_string() } else { @@ -2049,9 +2407,154 @@ fn verify_authorization_header(req: &Request, auth: &AuthConfig) -> Result<(), S status: 403, }); } + if payload_hash != STREAMING_SIGNED && payload_hash != STREAMING_SIGNED_TRAILER { + return Ok(None); + } + Ok(Some(ChunkSigning { + signing_key: derive_signing_key(&auth.secret_access_key, date_stamp, region), + amz_date: amz_date.to_string(), + scope: format!("{date_stamp}/{region}/{SIGV4_SERVICE}/aws4_request"), + seed_signature: expected, + has_trailer: payload_hash == STREAMING_SIGNED_TRAILER, + })) +} + +/// Rebuilds a request with its `aws-chunked` framing removed. With `signing` +/// present every chunk (and the trailer) must carry a valid signature chained +/// from the header's seed signature. +fn decode_streaming_request( + req: &Request, + signing: Option<&ChunkSigning>, +) -> Result { + let incomplete = || SignatureError { + code: "IncompleteBody", + status: 400, + }; + let parsed = aws_chunked::parse(&req.body).ok_or_else(incomplete)?; + if let Some(signing) = signing { + verify_chunk_signatures(&parsed, signing)?; + } + let body = parsed.payload(); + let decoded_length = req.header("x-amz-decoded-content-length"); + if !decoded_length.is_empty() && decoded_length.parse::().ok() != Some(body.len()) { + return Err(incomplete()); + } + let mut headers = req.headers.clone(); + let encodings: Vec<&str> = req + .header("content-encoding") + .split(',') + .map(str::trim) + .filter(|e| !e.is_empty() && !e.eq_ignore_ascii_case("aws-chunked")) + .collect(); + if encodings.is_empty() { + headers.remove("content-encoding"); + } else { + headers.insert("content-encoding".to_string(), encodings.join(", ")); + } + Ok(Request { + method: req.method.clone(), + path: req.path.clone(), + query: req.query.clone(), + headers, + body, + }) +} + +fn verify_chunk_signatures( + parsed: &aws_chunked::ChunkedBody<'_>, + signing: &ChunkSigning, +) -> Result<(), SignatureError> { + let sign = |algorithm: &str, previous: &str, parts: &[&str]| { + let mut fields = vec![algorithm, &signing.amz_date, &signing.scope, previous]; + fields.extend_from_slice(parts); + hex::encode(hmac_sha256( + &signing.signing_key, + fields.join("\n").as_bytes(), + )) + }; + let mismatch = || SignatureError { + code: "SignatureDoesNotMatch", + status: 403, + }; + let empty_hash = sha256_hex(b""); + let mut previous = signing.seed_signature.clone(); + for chunk in &parsed.chunks { + let expected = sign( + SIGV4_CHUNK_ALGORITHM, + &previous, + &[&empty_hash, &sha256_hex(chunk.data)], + ); + let actual = chunk.signature.ok_or_else(mismatch)?; + if !constant_time_eq(actual.as_bytes(), expected.as_bytes()) { + return Err(mismatch()); + } + previous = expected; + } + if signing.has_trailer { + let canonical_trailers: String = parsed + .trailers + .iter() + .map(|(name, value)| format!("{}:{value}\n", name.to_ascii_lowercase())) + .collect(); + let expected = sign( + SIGV4_TRAILER_ALGORITHM, + &previous, + &[&sha256_hex(canonical_trailers.as_bytes())], + ); + let actual = parsed.trailer_signature.ok_or_else(mismatch)?; + if !constant_time_eq(actual.as_bytes(), expected.as_bytes()) { + return Err(mismatch()); + } + } Ok(()) } +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn verify_scope_date(amz_date: &str, date_stamp: &str) -> Result<(), SignatureError> { + if amz_date.starts_with(date_stamp) && date_stamp.len() == 8 { + return Ok(()); + } + Err(SignatureError { + code: "AuthorizationHeaderMalformed", + status: 400, + }) +} + +/// `host` and every `x-amz-*` header that changes behaviour must be signed, or a +/// captured signature could be replayed with different semantics (for example +/// an added `x-amz-copy-source`). `x-amz-user-agent` is exempt because browser +/// SDKs send it unsigned and it has no effect on the request; +/// `x-amz-content-sha256` because its value is signed as the payload hash anyway. +fn verify_signed_header_coverage( + req: &Request, + signed_headers: &str, +) -> Result<(), SignatureError> { + let signed_headers = signed_headers.to_ascii_lowercase(); + let signed: Vec<&str> = signed_headers.split(';').map(str::trim).collect(); + let covered = signed.contains(&"host") + && req + .headers + .keys() + .filter(|name| { + name.starts_with("x-amz-") + && !matches!(name.as_str(), "x-amz-user-agent" | "x-amz-content-sha256") + }) + .all(|name| signed.contains(&name.as_str())); + if covered { + return Ok(()); + } + Err(SignatureError { + code: "AccessDenied", + status: 403, + }) +} + fn valid_credential(auth: &AuthConfig, access_key: &str, region: &str, service: &str) -> bool { let configured_access_key = if auth.access_key_id.is_empty() { "dev" @@ -2067,7 +2570,13 @@ fn valid_credential(auth: &AuthConfig, access_key: &str, region: &str, service: } fn verify_payload_hash(req: &Request, payload_hash: &str) -> Result<(), SignatureError> { - if payload_hash == "UNSIGNED-PAYLOAD" { + if matches!( + payload_hash, + "UNSIGNED-PAYLOAD" + | STREAMING_SIGNED + | STREAMING_SIGNED_TRAILER + | STREAMING_UNSIGNED_TRAILER + ) { return Ok(()); } if payload_hash.starts_with("STREAMING-") { @@ -2099,7 +2608,7 @@ fn signature_for_request( ) -> String { let canonical_request = [ req.method.clone(), - aws_percent_encode(&req.path, "/~"), + canonical_uri(&req.path), canonical_query_string(&req.query, ignored_query_key), canonical_headers(req, signed_headers), signed_headers.to_ascii_lowercase(), @@ -2126,6 +2635,16 @@ fn signature_for_request( )) } +/// `req.path` is the raw request target, so each segment is normalized through +/// a decode/encode round trip rather than encoded a second time. +fn canonical_uri(raw_path: &str) -> String { + raw_path + .split('/') + .map(|segment| aws_percent_encode(&percent_decode(segment), "~")) + .collect::>() + .join("/") +} + fn parse_credential_scope(credential: &str) -> Option<(&str, &str, &str, &str)> { let parts: Vec<&str> = credential.split('/').collect(); if parts.len() == 5 && parts[4] == "aws4_request" { @@ -2204,7 +2723,7 @@ fn sha256_hex(value: &[u8]) -> String { hex::encode(hasher.finalize()) } -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { +pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } @@ -2212,7 +2731,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { } fn parse_sigv4_time(value: &str) -> Option { - if value.len() != 16 || !value.ends_with('Z') { + if value.len() != 16 || !value.is_ascii() || !value.ends_with('Z') { return None; } let year = value[0..4].parse::().ok()?; @@ -2785,7 +3304,7 @@ fn tag_text(body: &[u8], tag: &str) -> Option { tag_text_in(xml, tag) } -fn tag_text_in(xml: &str, tag: &str) -> Option { +pub(crate) fn tag_text_in(xml: &str, tag: &str) -> Option { let open = format!("<{tag}>"); let close = format!(""); let start = xml.find(&open)? + open.len(); @@ -3504,7 +4023,7 @@ fn parse_target(target: &str) -> (String, BTreeMap) { (path.to_string(), values) } -fn percent_decode(value: &str) -> String { +pub(crate) fn percent_decode(value: &str) -> String { let bytes = value.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; @@ -3633,7 +4152,10 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { (if m <= 2 { y + 1 } else { y }, m, d) } -async fn write_response(stream: &mut TcpStream, response: Response) -> std::io::Result<()> { +pub(crate) async fn write_response( + stream: &mut TcpStream, + response: Response, +) -> std::io::Result<()> { let mut headers = response.headers; headers.insert("Server".to_string(), "AmazonS3".to_string()); // A 304 carries no body, so it must not advertise a zero Content-Length @@ -3687,3 +4209,123 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { } haystack.windows(needle.len()).position(|w| w == needle) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Feeds `wire` through an in-memory stream in small pieces, so reads split + /// across chunk boundaries are exercised. + async fn parse_wire(wire: &[u8]) -> Option { + let (mut client, mut server) = tokio::io::duplex(16); + let wire = wire.to_vec(); + tokio::spawn(async move { + let _ = client.write_all(&wire).await; + }); + read_request(&mut server).await.unwrap() + } + + fn wire(headers: &str, body: &str) -> Vec { + format!("PUT /data/k HTTP/1.1\r\nHost: h\r\n{headers}\r\n{body}").into_bytes() + } + + #[tokio::test] + async fn repeated_content_length_must_agree() { + let same = parse_wire(&wire("Content-Length: 5\r\nContent-Length: 5\r\n", "hello")).await; + assert_eq!(same.unwrap().body, b"hello"); + for headers in [ + "Content-Length: 5\r\nContent-Length: 4\r\n", + "Content-Length: abc\r\n", + "Content-Length: 5,\r\n", + ] { + assert!( + parse_wire(&wire(headers, "hello")).await.is_none(), + "{headers}" + ); + } + assert_eq!(parse_wire(&wire("", "")).await.unwrap().body, b""); + } + + #[tokio::test] + async fn http_chunked_bodies_are_decoded() { + let te = "Transfer-Encoding: chunked\r\n"; + let long = "x".repeat(70000); + let body = format!( + "5;ext=1\r\nhello\r\n{:x}\r\n{long}\r\n0\r\nTrailer: a\r\n\r\n", + long.len() + ); + let parsed = parse_wire(&wire(te, &body)).await.unwrap(); + assert_eq!(parsed.body, format!("hello{long}").into_bytes()); + for bad in [ + "5\r\nhello\r\n", + "5\r\nhelloXX0\r\n\r\n", + "zz\r\nx\r\n0\r\n\r\n", + "ffffffffffffffff\r\nx", + "0\r\n", + ] { + assert!(parse_wire(&wire(te, bad)).await.is_none(), "{bad}"); + } + } + + #[tokio::test] + async fn aws_chunked_payload_inside_http_chunked_reaches_the_object() { + let framed = "b\r\nhello world\r\n0\r\nx-amz-checksum-crc32:AAAA\r\n\r\n"; + let body = format!("{:x}\r\n{framed}\r\n0\r\n\r\n", framed.len()); + let headers = "Transfer-Encoding: chunked\r\nContent-Encoding: aws-chunked\r\n\ + x-amz-content-sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER\r\n"; + let request = parse_wire(&wire(headers, &body)).await.unwrap(); + + let root = std::env::temp_dir().join(format!("devcloud-s3-te-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let store = FileBucketStore::new(&root); + assert_eq!( + route(&store, &Request::new("PUT", "/data", Vec::new())).status, + 200 + ); + assert_eq!(route(&store, &request).status, 200); + let got = route(&store, &Request::new("GET", "/data/k", Vec::new())); + assert_eq!(got.body, b"hello world"); + } + + /// The worked example in AWS's "Signature Calculations for the Authorization + /// Header: Transferring Payload in Multiple Chunks" documentation. + #[test] + fn chunk_signatures_match_the_aws_documented_example() { + let date_stamp = "20130524"; + let signing = ChunkSigning { + signing_key: derive_signing_key( + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + date_stamp, + "us-east-1", + ), + amz_date: "20130524T000000Z".to_string(), + scope: format!("{date_stamp}/us-east-1/s3/aws4_request"), + seed_signature: "4f232c4386841ef735655705268965c44a0e4690baa4adea153f7db9fa80a0a9" + .to_string(), + has_trailer: false, + }; + let mut body = Vec::new(); + for (size, signature) in [ + ( + 65536, + "ad80c730a21e5b8d04586a2213dd63b9a0e99e0e2307b0ade35a65485a288648", + ), + ( + 1024, + "0055627c9e194cb4542bae2aa5492e3c1575bbb81b612b7d234b86a503ef5497", + ), + ( + 0, + "b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9", + ), + ] { + body.extend(format!("{size:x};chunk-signature={signature}\r\n").into_bytes()); + body.extend(vec![b'a'; size]); + body.extend_from_slice(b"\r\n"); + } + let parsed = aws_chunked::parse(&body).unwrap(); + assert_eq!(parsed.payload().len(), 66560); + assert!(verify_chunk_signatures(&parsed, &signing).is_ok()); + } +} diff --git a/services/s3/src/lib.rs b/services/s3/src/lib.rs index a0f5e0d..1e3405d 100644 --- a/services/s3/src/lib.rs +++ b/services/s3/src/lib.rs @@ -24,7 +24,10 @@ pub(crate) fn event_sink() -> Option<&'static UnboundedSender> { EVENT_SINK.get() } +pub mod aws_chunked; pub mod base64; +pub mod console; +pub mod cors; pub mod csv; pub mod hashes; pub mod http; diff --git a/services/s3/src/main.rs b/services/s3/src/main.rs index 670f7c4..7a06cdd 100644 --- a/services/s3/src/main.rs +++ b/services/s3/src/main.rs @@ -7,22 +7,65 @@ //! DEVCLOUD_S3_AUTH_MODE relaxed (default) or strict //! DEVCLOUD_S3_ACCESS_KEY_ID / DEVCLOUD_S3_SECRET_ACCESS_KEY strict-mode creds //! DEVCLOUD_S3_REGION SigV4 region +//! DEVCLOUD_S3_CORS_ALLOW_ORIGINS comma-separated origins for buckets without +//! their own CORS configuration; unset allows none +//! DEVCLOUD_S3_CONSOLE_ADDR web console listen address; unset disables it +//! DEVCLOUD_S3_CONSOLE_ALLOWED_HOSTS extra comma-separated console Host names use std::sync::{Arc, Mutex}; +use devcloud_s3::console::{self, ConsoleConfig}; +use devcloud_s3::cors; use devcloud_s3::http::AuthConfig; use devcloud_s3::store::FileBucketStore; +use tokio::net::TcpListener; +use tokio::sync::watch; fn env(name: &str) -> String { std::env::var(name).unwrap_or_default() } +fn env_list(name: &str) -> Vec { + env(name) + .split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .collect() +} + +fn fail(code: i32, message: &str) -> ! { + eprintln!("devcloud-s3: {message}"); + std::process::exit(code); +} + +/// A mistyped mode must not silently disable signature checks, and strict mode +/// must not silently fall back to the built-in development credentials. +fn validate_auth(auth: &AuthConfig) { + let mode = auth.auth_mode.to_ascii_lowercase(); + if !matches!(mode.as_str(), "" | "relaxed" | "strict") { + fail(2, "DEVCLOUD_S3_AUTH_MODE must be relaxed or strict"); + } + if mode == "strict" && (auth.access_key_id.is_empty() || auth.secret_access_key.is_empty()) { + fail( + 2, + "strict auth requires DEVCLOUD_S3_ACCESS_KEY_ID and DEVCLOUD_S3_SECRET_ACCESS_KEY", + ); + } +} + +async fn bind(addr: &str) -> TcpListener { + match TcpListener::bind(addr).await { + Ok(listener) => listener, + Err(e) => fail(1, &format!("bind {addr}: {e}")), + } +} + fn main() { let addr = std::env::var("DEVCLOUD_S3_ADDR").unwrap_or_else(|_| "127.0.0.1:0".to_string()); let storage = env("DEVCLOUD_S3_STORAGE"); if storage.is_empty() { - eprintln!("devcloud-s3: DEVCLOUD_S3_STORAGE is required"); - std::process::exit(2); + fail(2, "DEVCLOUD_S3_STORAGE is required"); } let store = Arc::new(Mutex::new(FileBucketStore::new(storage))); let auth = AuthConfig { @@ -31,6 +74,13 @@ fn main() { secret_access_key: env("DEVCLOUD_S3_SECRET_ACCESS_KEY"), region: env("DEVCLOUD_S3_REGION"), }; + validate_auth(&auth); + let default_cors = cors::default_rule(&env_list("DEVCLOUD_S3_CORS_ALLOW_ORIGINS")); + let console_addr = env("DEVCLOUD_S3_CONSOLE_ADDR"); + let console_config = ConsoleConfig { + auth: auth.clone(), + allowed_hosts: env_list("DEVCLOUD_S3_CONSOLE_ALLOWED_HOSTS"), + }; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -38,18 +88,41 @@ fn main() { .expect("build tokio runtime"); runtime.block_on(async move { - let listener = match tokio::net::TcpListener::bind(&addr).await { - Ok(l) => l, - Err(e) => { - eprintln!("devcloud-s3: bind {addr}: {e}"); - std::process::exit(1); + let listener = bind(&addr).await; + let console_listener = if console_addr.is_empty() { + None + } else { + Some(bind(&console_addr).await) + }; + + let (stop, stopped) = watch::channel(false); + tokio::spawn(async move { + shutdown_signal().await; + let _ = stop.send(true); + }); + let wait_for_stop = |mut stopped: watch::Receiver| async move { + let _ = stopped.changed().await; + }; + + let s3 = devcloud_s3::http::serve_with_cors( + listener, + Arc::clone(&store), + auth, + default_cors, + wait_for_stop(stopped.clone()), + ); + let console = async { + match console_listener { + Some(listener) => { + console::serve(listener, store, console_config, wait_for_stop(stopped)).await + } + None => Ok(()), } }; - if let Err(e) = - devcloud_s3::http::serve_with_auth(listener, store, auth, shutdown_signal()).await - { - eprintln!("devcloud-s3: serve error: {e}"); - std::process::exit(1); + // Either listener failing ends the process, so a restart policy can act + // instead of the container looking healthy with one server gone. + if let Err(e) = tokio::try_join!(s3, console) { + fail(1, &format!("serve error: {e}")); } }); } diff --git a/services/s3/src/store.rs b/services/s3/src/store.rs index 63024fc..daafcc0 100644 --- a/services/s3/src/store.rs +++ b/services/s3/src/store.rs @@ -24,6 +24,7 @@ const SIDECAR_FILES: &[&str] = &[ "bucket.json", "versioning.json", "policy.json", + "cors.json", "acl.json", "lifecycle.json", "notification.json", diff --git a/services/s3/src/store_config.rs b/services/s3/src/store_config.rs index 353e2c3..2ddc98f 100644 --- a/services/s3/src/store_config.rs +++ b/services/s3/src/store_config.rs @@ -2,6 +2,7 @@ //! lifecycle (including expiration application), ACL, and policy. Notification, //! inventory, analytics, and replication land in a later part. +use crate::cors::CorsConfiguration; use crate::model::{ AnalyticsConfiguration, LifecycleConfiguration, NotificationConfiguration, NotificationEventRecord, Object, ObjectLockConfiguration, ReplicationConfiguration, @@ -213,6 +214,37 @@ impl FileBucketStore { Ok(true) } + // --- CORS --------------------------------------------------------------- + + /// Stores the bucket CORS configuration. Errors if the bucket is absent. + pub fn put_bucket_cors(&self, bucket: &str, config: &CorsConfiguration) -> Result<()> { + self.get_bucket(bucket)?.ok_or(StoreError::BucketNotExist)?; + let data = serde_json::to_vec(config).map_err(io::Error::other)?; + std::fs::write(self.bucket_path(bucket).join("cors.json"), data)?; + Ok(()) + } + + /// Reads the bucket CORS configuration; `None` if none is set. + pub fn get_bucket_cors(&self, bucket: &str) -> Result> { + if self.get_bucket(bucket)?.is_none() { + return Ok(None); + } + match std::fs::read(self.bucket_path(bucket).join("cors.json")) { + Ok(data) => Ok(Some( + serde_json::from_slice(&data).map_err(io::Error::other)?, + )), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(StoreError::Io(e)), + } + } + + /// Removes the bucket CORS configuration. Errors if the bucket is absent. + pub fn delete_bucket_cors(&self, bucket: &str) -> Result<()> { + self.get_bucket(bucket)?.ok_or(StoreError::BucketNotExist)?; + remove_if_exists(&self.bucket_path(bucket).join("cors.json"))?; + Ok(()) + } + // --- ACL ---------------------------------------------------------------- /// Sets the bucket ACL (`bucket.json` + `acl.json`). Errors if absent. diff --git a/services/s3/tests/console.rs b/services/s3/tests/console.rs new file mode 100644 index 0000000..8611a22 --- /dev/null +++ b/services/s3/tests/console.rs @@ -0,0 +1,336 @@ +use devcloud_s3::console::{dispatch, host_allowed, ConsoleConfig}; +use devcloud_s3::http::{route, AuthConfig, Request, Response}; +use devcloud_s3::store::FileBucketStore; + +fn store() -> FileBucketStore { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "devcloud-s3-console-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + FileBucketStore::new(dir) +} + +fn relaxed() -> ConsoleConfig { + ConsoleConfig { + auth: AuthConfig::default(), + allowed_hosts: Vec::new(), + } +} + +fn strict() -> ConsoleConfig { + ConsoleConfig { + auth: AuthConfig { + auth_mode: "strict".to_string(), + access_key_id: "key".to_string(), + secret_access_key: "secret".to_string(), + region: "ap-northeast-1".to_string(), + }, + allowed_hosts: Vec::new(), + } +} + +fn request(method: &str, target: &str, body: &[u8]) -> Request { + let mut req = Request::new(method, target, body.to_vec()); + req.headers + .insert("host".to_string(), "localhost:9001".to_string()); + req +} + +fn json(response: &Response) -> serde_json::Value { + serde_json::from_slice(&response.body).unwrap() +} + +#[test] +fn serves_the_embedded_app_with_a_restrictive_policy() { + let store = store(); + let index = dispatch(&store, &request("GET", "/", b""), &relaxed()); + assert_eq!(index.status, 200); + assert!(index.headers["Content-Type"].starts_with("text/html")); + assert!(String::from_utf8_lossy(&index.body).contains("devcloud S3 console")); + assert!(index.headers["Content-Security-Policy"].contains("default-src 'self'")); + assert_eq!(index.headers["X-Content-Type-Options"], "nosniff"); + + assert_eq!( + dispatch(&store, &request("GET", "/missing.js", b""), &relaxed()).status, + 404 + ); + assert_eq!( + dispatch( + &store, + &request("GET", "/../../Cargo.toml", b""), + &relaxed() + ) + .status, + 404 + ); + assert_eq!( + dispatch(&store, &request("POST", "/", b""), &relaxed()).status, + 405 + ); +} + +#[test] +fn bucket_and_object_workflow_reaches_the_s3_store() { + let store = store(); + let config = relaxed(); + let send = |method: &str, target: &str, body: &[u8]| { + dispatch(&store, &request(method, target, body), &config) + }; + + assert_eq!(send("PUT", "/api/buckets/photos", b"").status, 200); + let duplicate = send("PUT", "/api/buckets/photos", b""); + assert_eq!(duplicate.status, 409); + assert_eq!(json(&duplicate)["code"], "BucketAlreadyOwnedByYou"); + assert_eq!(send("PUT", "/api/buckets/Bad_Name", b"").status, 400); + + let mut upload = request( + "PUT", + "/api/buckets/photos/object?key=2026%2Fa%20b%E3%81%82.txt", + b"hello", + ); + upload + .headers + .insert("content-type".to_string(), "text/plain".to_string()); + assert_eq!(dispatch(&store, &upload, &config).status, 200); + // Written through the provider path: visible to S3 clients under the decoded key. + let via_s3 = route( + &store, + &Request::new("GET", "/photos/2026/a%20b%E3%81%82.txt", Vec::new()), + ); + assert_eq!(via_s3.body, b"hello"); + assert_eq!(via_s3.headers["Content-Type"], "text/plain"); + + let buckets = json(&send("GET", "/api/buckets", b"")); + assert_eq!(buckets["buckets"][0]["name"], "photos"); + assert_eq!(buckets["buckets"][0]["objectCount"], 1); + + let listed = json(&send( + "GET", + "/api/buckets/photos/objects?prefix=2026%2F", + b"", + )); + assert_eq!(listed["objects"][0]["key"], "2026/a bใ‚.txt"); + assert_eq!(listed["objects"][0]["size"], 5); + let other = json(&send( + "GET", + "/api/buckets/photos/objects?prefix=2025%2F", + b"", + )); + assert_eq!(other["objects"].as_array().unwrap().len(), 0); + + let download = send( + "GET", + "/api/buckets/photos/object?key=2026%2Fa%20b%E3%81%82.txt", + b"", + ); + assert_eq!(download.status, 200); + assert_eq!(download.body, b"hello"); + assert_eq!( + download.headers["Content-Disposition"], + "attachment; filename*=UTF-8''a%20b%E3%81%82.txt" + ); + + let not_empty = send("DELETE", "/api/buckets/photos", b""); + assert_eq!(not_empty.status, 409); + assert_eq!(json(¬_empty)["code"], "BucketNotEmpty"); + + assert_eq!( + send( + "DELETE", + "/api/buckets/photos/object?key=2026%2Fa%20b%E3%81%82.txt", + b"" + ) + .status, + 204 + ); + let gone = send( + "GET", + "/api/buckets/photos/object?key=2026%2Fa%20b%E3%81%82.txt", + b"", + ); + assert_eq!(gone.status, 404); + assert_eq!(json(&gone)["code"], "NoSuchKey"); + assert_eq!(send("DELETE", "/api/buckets/photos", b"").status, 204); +} + +#[test] +fn dot_segment_keys_address_their_own_object() { + let store = store(); + let config = relaxed(); + let send = |method: &str, target: &str, body: &[u8]| { + dispatch(&store, &request(method, target, body), &config) + }; + assert_eq!(send("PUT", "/api/buckets/dots", b"").status, 200); + assert_eq!( + route( + &store, + &Request::new("PUT", "/dots/x/../y", b"dotted".to_vec()) + ) + .status, + 200 + ); + assert_eq!( + route(&store, &Request::new("PUT", "/dots/y", b"plain".to_vec())).status, + 200 + ); + + assert_eq!( + send("GET", "/api/buckets/dots/object?key=x%2F..%2Fy", b"").body, + b"dotted" + ); + assert_eq!( + send("DELETE", "/api/buckets/dots/object?key=x%2F..%2Fy", b"").status, + 204 + ); + assert_eq!( + route(&store, &Request::new("GET", "/dots/x/../y", Vec::new())).status, + 404 + ); + assert_eq!( + route(&store, &Request::new("GET", "/dots/y", Vec::new())).body, + b"plain" + ); + + assert_eq!(send("GET", "/api/buckets/dots/object", b"").status, 400); + assert_eq!(send("GET", "/api/buckets/dots/objects/y", b"").status, 404); +} + +#[test] +fn stored_html_downloads_instead_of_rendering_on_the_console_origin() { + let store = store(); + let config = relaxed(); + assert_eq!( + dispatch(&store, &request("PUT", "/api/buckets/web", b""), &config).status, + 200 + ); + let mut put = Request::new("PUT", "/web/x.html", b"".to_vec()); + put.headers + .insert("content-type".to_string(), "text/html".to_string()); + put.headers + .insert("content-disposition".to_string(), "inline".to_string()); + assert_eq!(route(&store, &put).status, 200); + + let download = dispatch( + &store, + &request("GET", "/api/buckets/web/object?key=x.html", b""), + &config, + ); + assert_eq!(download.status, 200); + assert!(download.headers["Content-Disposition"].starts_with("attachment")); + assert!(download.headers["Content-Security-Policy"].contains("default-src 'none'")); + assert_eq!(download.headers["X-Content-Type-Options"], "nosniff"); +} + +#[test] +fn cross_site_requests_cannot_mutate() { + let store = store(); + let config = relaxed(); + assert_eq!( + dispatch(&store, &request("PUT", "/api/buckets/keep", b""), &config).status, + 200 + ); + // HTML forms only send GET and POST, and no mutation uses them; everything + // else needs a preflight, which gets no CORS grant. + for target in [ + "/api/buckets/keep", + "/api/buckets/keep/object?key=k", + "/api/buckets", + ] { + for method in ["POST", "OPTIONS"] { + let mut cross_site = request(method, target, b"x"); + cross_site + .headers + .insert("origin".to_string(), "https://evil.test".to_string()); + let response = dispatch(&store, &cross_site, &config); + assert_eq!(response.status, 405, "{method} {target}"); + assert!(!response.headers.contains_key("Access-Control-Allow-Origin")); + } + } + let buckets = json(&dispatch( + &store, + &request("GET", "/api/buckets", b""), + &config, + )); + assert_eq!(buckets["buckets"][0]["objectCount"], 0); +} + +#[test] +fn rebinding_hosts_are_denied_unless_listed() { + let store = store(); + let mut rebind = request("GET", "/api/buckets", b""); + rebind + .headers + .insert("host".to_string(), "evil.example.com".to_string()); + assert_eq!(dispatch(&store, &rebind, &relaxed()).status, 403); + + let mut config = relaxed(); + config.allowed_hosts = vec!["evil.example.com".to_string()]; + assert_eq!(dispatch(&store, &rebind, &config).status, 200); + + for allowed in [ + "localhost:9001", + "127.0.0.1:9001", + "[::1]:9001", + "host.docker.internal:9001", + "s3-console", + ] { + assert!(host_allowed(allowed, &[]), "{allowed}"); + } + for denied in [ + "", + "a.example.com", + "a b", + "x@localhost", + "[::1", + "localhost:notaport", + ] { + assert!(!host_allowed(denied, &[]), "{denied}"); + } +} + +#[test] +fn strict_mode_requires_the_configured_credentials() { + let store = store(); + let config = strict(); + for target in ["/", "/api/buckets", "/api/info"] { + let response = dispatch(&store, &request("GET", target, b""), &config); + assert_eq!(response.status, 401, "{target}"); + assert!(response.headers["WWW-Authenticate"].starts_with("Basic")); + } + let with_basic = |credentials: &str, method: &str, target: &str| { + let mut req = request(method, target, b""); + req.headers.insert( + "authorization".to_string(), + format!( + "Basic {}", + devcloud_s3::base64::std_encode(credentials.as_bytes()) + ), + ); + dispatch(&store, &req, &config) + }; + for wrong in ["key:wrong", "wrong:secret", "key", ":"] { + assert_eq!( + with_basic(wrong, "GET", "/api/buckets").status, + 401, + "{wrong}" + ); + } + let info = with_basic("key:secret", "GET", "/api/info"); + assert_eq!(json(&info)["authMode"], "strict"); + assert_eq!(json(&info)["region"], "ap-northeast-1"); + // No SigV4 signature exists here: Basic alone authorizes the forwarded call. + assert_eq!( + with_basic("key:secret", "PUT", "/api/buckets/strict-ok").status, + 200 + ); + let mut unsigned_garbage = request("GET", "/api/buckets", b""); + unsigned_garbage + .headers + .insert("authorization".to_string(), "Basic !!!".to_string()); + assert_eq!(dispatch(&store, &unsigned_garbage, &config).status, 401); +} diff --git a/services/s3/tests/cors.rs b/services/s3/tests/cors.rs new file mode 100644 index 0000000..bf0f97e --- /dev/null +++ b/services/s3/tests/cors.rs @@ -0,0 +1,365 @@ +use devcloud_s3::cors::default_rule; +use devcloud_s3::http::{route, route_with_auth, route_with_cors, AuthConfig, Request, Response}; +use devcloud_s3::store::FileBucketStore; + +const CONFIG: &str = r#" + + app + https://app.example.com + http://*.local.test + GET + PUT + x-amz-* + Content-Type + ETag + 600 + + + * + HEAD + +"#; + +fn store() -> FileBucketStore { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "devcloud-s3-cors-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + FileBucketStore::new(dir) +} + +fn request(method: &str, target: &str, headers: &[(&str, &str)], body: &[u8]) -> Request { + let mut req = Request::new(method, target, body.to_vec()); + for (name, value) in headers { + req.headers.insert(name.to_string(), value.to_string()); + } + req +} + +fn preflight(origin: &str, method: &str, headers: &str) -> Request { + let mut fields = vec![ + ("origin", origin), + ("access-control-request-method", method), + ]; + if !headers.is_empty() { + fields.push(("access-control-request-headers", headers)); + } + request("OPTIONS", "/data/key.txt", &fields, b"") +} + +fn configured_store() -> FileBucketStore { + let store = store(); + assert_eq!( + route(&store, &request("PUT", "/data", &[], b"")).status, + 200 + ); + let put = request("PUT", "/data?cors", &[], CONFIG.as_bytes()); + assert_eq!(route(&store, &put).status, 200); + store +} + +fn header<'a>(response: &'a Response, name: &str) -> Option<&'a str> { + response.headers.get(name).map(String::as_str) +} + +fn body(response: &Response) -> String { + String::from_utf8_lossy(&response.body).into_owned() +} + +fn strict() -> AuthConfig { + AuthConfig { + auth_mode: "strict".to_string(), + access_key_id: "key".to_string(), + secret_access_key: "secret".to_string(), + region: "us-east-1".to_string(), + } +} + +#[test] +fn configuration_round_trips_and_deletes() { + let store = store(); + assert_eq!( + route(&store, &request("PUT", "/data", &[], b"")).status, + 200 + ); + + let missing = route(&store, &request("GET", "/data?cors", &[], b"")); + assert_eq!(missing.status, 404); + assert!(body(&missing).contains("NoSuchCORSConfiguration")); + + let put = request("PUT", "/data?cors", &[], CONFIG.as_bytes()); + assert_eq!(route(&store, &put).status, 200); + let got = route(&store, &request("GET", "/data?cors", &[], b"")); + assert_eq!(got.status, 200); + let xml = body(&got); + assert!(xml.contains("app")); + assert!(xml.contains("http://*.local.test")); + assert!(xml.contains("Content-Type")); + assert!(xml.contains("600")); + + assert_eq!( + route(&store, &request("DELETE", "/data?cors", &[], b"")).status, + 204 + ); + assert_eq!( + route(&store, &request("GET", "/data?cors", &[], b"")).status, + 404 + ); +} + +#[test] +fn invalid_configurations_are_rejected() { + let store = store(); + assert_eq!( + route(&store, &request("PUT", "/data", &[], b"")).status, + 200 + ); + let rule = |inner: &str| { + format!("{inner}") + }; + for xml in [ + "not xml".to_string(), + "".to_string(), + rule("GET"), + rule("*"), + rule("*PATCH"), + rule("*.*GET"), + rule("*GETx"), + rule("*GET*"), + rule("*GETX\r\nSet-Cookie: a=b"), + rule("*GETA B"), + ] { + let response = route(&store, &request("PUT", "/data?cors", &[], xml.as_bytes())); + assert_eq!(response.status, 400, "{xml}"); + assert!(body(&response).contains("MalformedXML"), "{xml}"); + } + let absent = request("PUT", "/absent?cors", &[], CONFIG.as_bytes()); + assert_eq!(route(&store, &absent).status, 404); +} + +#[test] +fn preflight_matches_origin_method_and_headers() { + let store = configured_store(); + + let allowed = route( + &store, + &preflight("https://app.example.com", "PUT", "Content-Type, X-Amz-Date"), + ); + assert_eq!(allowed.status, 200); + assert_eq!( + header(&allowed, "Access-Control-Allow-Origin"), + Some("https://app.example.com") + ); + assert_eq!( + header(&allowed, "Access-Control-Allow-Methods"), + Some("GET, PUT") + ); + assert_eq!( + header(&allowed, "Access-Control-Allow-Headers"), + Some("content-type, x-amz-date") + ); + assert_eq!( + header(&allowed, "Access-Control-Allow-Credentials"), + Some("true") + ); + assert_eq!(header(&allowed, "Access-Control-Max-Age"), Some("600")); + assert_eq!( + header(&allowed, "Access-Control-Expose-Headers"), + Some("ETag") + ); + + let wildcard = route(&store, &preflight("http://dev.local.test", "GET", "")); + assert_eq!(wildcard.status, 200); + assert_eq!( + header(&wildcard, "Access-Control-Allow-Origin"), + Some("http://dev.local.test") + ); + + let any = route(&store, &preflight("https://other.test", "HEAD", "")); + assert_eq!(any.status, 200); + assert_eq!(header(&any, "Access-Control-Allow-Origin"), Some("*")); + assert_eq!(header(&any, "Access-Control-Allow-Credentials"), None); + + for denied in [ + preflight("https://evil.test", "PUT", ""), + preflight("https://app.example.com", "DELETE", ""), + preflight("https://app.example.com", "PUT", "authorization"), + preflight("HTTPS://APP.EXAMPLE.COM", "PUT", ""), + ] { + let response = route(&store, &denied); + assert_eq!(response.status, 403); + assert!(body(&response).contains("CORSResponse")); + assert_eq!(header(&response, "Access-Control-Allow-Origin"), None); + } +} + +#[test] +fn preflight_without_configuration_or_required_headers() { + let store = store(); + let absent = route(&store, &preflight("https://a.test", "GET", "")); + assert_eq!(absent.status, 404); + + assert_eq!( + route(&store, &request("PUT", "/data", &[], b"")).status, + 200 + ); + let disabled = route(&store, &preflight("https://a.test", "GET", "")); + assert_eq!(disabled.status, 403); + assert!(body(&disabled).contains("CORS is not enabled")); + + let no_origin = request( + "OPTIONS", + "/data", + &[("access-control-request-method", "GET")], + b"", + ); + assert_eq!(route(&store, &no_origin).status, 400); + let no_method = request("OPTIONS", "/data", &[("origin", "https://a.test")], b""); + assert_eq!(route(&store, &no_method).status, 400); +} + +#[test] +fn actual_requests_carry_cors_headers_only_when_matched() { + let store = configured_store(); + let origin = [("origin", "https://app.example.com")]; + let put = route(&store, &request("PUT", "/data/key.txt", &origin, b"hello")); + assert_eq!(put.status, 200); + assert_eq!( + header(&put, "Access-Control-Allow-Origin"), + Some("https://app.example.com") + ); + assert!(header(&put, "Vary").unwrap().contains("Origin")); + + // Browser clients need the headers to read error responses too. + let missing = route(&store, &request("GET", "/data/none.txt", &origin, b"")); + assert_eq!(missing.status, 404); + assert_eq!( + header(&missing, "Access-Control-Allow-Origin"), + Some("https://app.example.com") + ); + + let other = [("origin", "https://evil.test")]; + let denied = route(&store, &request("GET", "/data/key.txt", &other, b"")); + assert_eq!(denied.status, 200); + assert_eq!(header(&denied, "Access-Control-Allow-Origin"), None); + assert!(header(&denied, "Vary").is_some()); + + let plain = route(&store, &request("GET", "/data/key.txt", &[], b"")); + assert_eq!(header(&plain, "Access-Control-Allow-Origin"), None); + assert_eq!(header(&plain, "Vary"), None); +} + +#[test] +fn strict_mode_answers_preflight_without_a_signature_but_still_rejects_requests() { + let store = configured_store(); + let auth = strict(); + let allowed = route_with_auth( + &store, + &preflight("https://app.example.com", "PUT", ""), + &auth, + ); + assert_eq!(allowed.status, 200); + + let origin = [("origin", "https://app.example.com")]; + let unsigned = route_with_auth( + &store, + &request("GET", "/data/key.txt", &origin, b""), + &auth, + ); + assert_eq!(unsigned.status, 403); + assert!(body(&unsigned).contains("AccessDenied")); + assert_eq!( + header(&unsigned, "Access-Control-Allow-Origin"), + Some("https://app.example.com") + ); +} + +#[test] +fn server_default_applies_until_a_bucket_defines_its_own_rules() { + let store = store(); + let auth = AuthConfig::default(); + let rule = default_rule(&["https://app.example.com".to_string()]).unwrap(); + let default = Some(&rule); + + // The default also covers ListBuckets and creating a bucket that does not exist yet. + let root = request( + "OPTIONS", + "/", + &[ + ("origin", "https://app.example.com"), + ("access-control-request-method", "GET"), + ], + b"", + ); + assert_eq!(route_with_cors(&store, &root, &auth, default).status, 200); + assert_eq!(route_with_cors(&store, &root, &auth, None).status, 403); + + let origin = [("origin", "https://app.example.com")]; + let create = route_with_cors( + &store, + &request("PUT", "/data", &origin, b""), + &auth, + default, + ); + assert_eq!(create.status, 200); + assert_eq!( + header(&create, "Access-Control-Allow-Origin"), + Some("https://app.example.com") + ); + assert!(header(&create, "Access-Control-Expose-Headers") + .unwrap() + .contains("ETag")); + let with_any_header = preflight("https://app.example.com", "DELETE", "x-custom"); + assert_eq!( + route_with_cors(&store, &with_any_header, &auth, default).status, + 200 + ); + + let own = "https://own.test\ + GET"; + let put = request("PUT", "/data?cors", &[], own.as_bytes()); + assert_eq!(route(&store, &put).status, 200); + let replaced = preflight("https://app.example.com", "GET", ""); + assert_eq!( + route_with_cors(&store, &replaced, &auth, default).status, + 403 + ); +} + +#[test] +fn bucket_with_only_a_cors_configuration_can_be_deleted() { + let store = configured_store(); + let origin = [("origin", "https://app.example.com")]; + let deleted = route(&store, &request("DELETE", "/data", &origin, b"")); + assert_eq!(deleted.status, 204); + assert_eq!( + header(&deleted, "Access-Control-Allow-Origin"), + None, + "DELETE is not allowed by the configured rules" + ); + assert_eq!( + route(&store, &request("HEAD", "/data", &[], b"")).status, + 404 + ); +} + +#[test] +fn introspection_paths_are_not_cors_enabled() { + let store = configured_store(); + let auth = AuthConfig::default(); + let rule = default_rule(&["*".to_string()]).unwrap(); + let origin = [("origin", "https://app.example.com")]; + let response = route_with_cors( + &store, + &request("GET", "/_introspect/buckets", &origin, b""), + &auth, + Some(&rule), + ); + assert_eq!(response.status, 200); + assert_eq!(header(&response, "Access-Control-Allow-Origin"), None); +} diff --git a/services/s3/tests/http_parity.rs b/services/s3/tests/http_parity.rs index ef72e2d..df0a41f 100644 --- a/services/s3/tests/http_parity.rs +++ b/services/s3/tests/http_parity.rs @@ -69,11 +69,18 @@ fn strict_auth() -> AuthConfig { } fn signed_req(method: &str, target: &str, body: &[u8]) -> Request { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let timestamp = devcloud_s3::time_fmt::rfc3339_seconds_from_unix(now); + let amz_date = timestamp.replace(['-', ':'], ""); + let date_stamp = &amz_date[..8]; let mut req = Request::new(method, target, body.to_vec()); req.headers .insert("host".to_string(), "example.com".to_string()); req.headers - .insert("x-amz-date".to_string(), "20260430T120000Z".to_string()); + .insert("x-amz-date".to_string(), amz_date.clone()); let body_hash = sha256_hex(body); req.headers .insert("x-amz-content-sha256".to_string(), body_hash.clone()); @@ -82,23 +89,21 @@ fn signed_req(method: &str, target: &str, body: &[u8]) -> Request { method.to_string(), target.to_string(), String::new(), - format!( - "host:example.com\nx-amz-content-sha256:{body_hash}\nx-amz-date:20260430T120000Z\n" - ), + format!("host:example.com\nx-amz-content-sha256:{body_hash}\nx-amz-date:{amz_date}\n"), signed_headers.to_string(), body_hash, ] .join("\n"); - let scope = "20260430/us-east-1/s3/aws4_request"; + let scope = format!("{date_stamp}/us-east-1/s3/aws4_request"); let string_to_sign = [ "AWS4-HMAC-SHA256".to_string(), - "20260430T120000Z".to_string(), - scope.to_string(), + amz_date.clone(), + scope.clone(), sha256_hex(canonical_request.as_bytes()), ] .join("\n"); let signature = hex::encode(hmac_sha256( - &test_signing_key("dev", "20260430", "us-east-1"), + &test_signing_key("dev", date_stamp, "us-east-1"), string_to_sign.as_bytes(), )); req.headers.insert( diff --git a/services/s3/tests/sigv4.rs b/services/s3/tests/sigv4.rs new file mode 100644 index 0000000..14c1b8e --- /dev/null +++ b/services/s3/tests/sigv4.rs @@ -0,0 +1,623 @@ +use devcloud_s3::http::{route, route_with_auth, AuthConfig, Request, Response}; +use devcloud_s3::percent::aws_percent_encode; +use devcloud_s3::store::FileBucketStore; +use devcloud_s3::time_fmt::rfc3339_seconds_from_unix; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; + +const SECRET: &str = "test-secret"; +const STREAMING: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"; +const STREAMING_TRAILER: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER"; +const UNSIGNED_TRAILER: &str = "STREAMING-UNSIGNED-PAYLOAD-TRAILER"; + +fn store() -> FileBucketStore { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "devcloud-s3-sigv4-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let store = FileBucketStore::new(dir); + assert_eq!( + route(&store, &Request::new("PUT", "/data", Vec::new())).status, + 200 + ); + store +} + +fn auth() -> AuthConfig { + AuthConfig { + auth_mode: "strict".to_string(), + access_key_id: "test-key".to_string(), + secret_access_key: SECRET.to_string(), + region: "us-east-1".to_string(), + } +} + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +fn amz_date(unix: i64) -> String { + rfc3339_seconds_from_unix(unix).replace(['-', ':'], "") +} + +fn hmac_sha256(key: &[u8], value: &[u8]) -> Vec { + let mut mac = Hmac::::new_from_slice(key).unwrap(); + mac.update(value); + mac.finalize().into_bytes().to_vec() +} + +fn sha256_hex(value: &[u8]) -> String { + hex::encode(Sha256::digest(value)) +} + +fn signing_key(date_stamp: &str) -> Vec { + let date = hmac_sha256(format!("AWS4{SECRET}").as_bytes(), date_stamp.as_bytes()); + let region = hmac_sha256(&date, b"us-east-1"); + let service = hmac_sha256(®ion, b"s3"); + hmac_sha256(&service, b"aws4_request") +} + +struct Signed { + request: Request, + signature: String, + amz_date: String, +} + +/// Signs independently of the server, the way an SDK does: `target` is the +/// already encoded wire path, used verbatim as the canonical URI. +fn sign_at( + method: &str, + target: &str, + body: Vec, + extra_headers: &[(&str, &str)], + payload_hash: &str, + unix: i64, +) -> Signed { + let date = amz_date(unix); + let date_stamp = &date[..8]; + let mut request = Request::new(method, target, body); + request + .headers + .insert("host".to_string(), "s3.test:9000".to_string()); + request + .headers + .insert("x-amz-date".to_string(), date.clone()); + request + .headers + .insert("x-amz-content-sha256".to_string(), payload_hash.to_string()); + for (name, value) in extra_headers { + request.headers.insert(name.to_string(), value.to_string()); + } + let names: Vec<&String> = request.headers.keys().collect(); + let signed_headers = names + .iter() + .map(|n| n.as_str()) + .collect::>() + .join(";"); + let canonical_headers: String = request + .headers + .iter() + .map(|(name, value)| format!("{name}:{value}\n")) + .collect(); + let canonical_request = [ + method, + target.split('?').next().unwrap(), + "", + &canonical_headers, + &signed_headers, + payload_hash, + ] + .join("\n"); + let scope = format!("{date_stamp}/us-east-1/s3/aws4_request"); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{date}\n{scope}\n{}", + sha256_hex(canonical_request.as_bytes()) + ); + let signature = hex::encode(hmac_sha256( + &signing_key(date_stamp), + string_to_sign.as_bytes(), + )); + request.headers.insert( + "authorization".to_string(), + format!( + "AWS4-HMAC-SHA256 Credential=test-key/{scope}, SignedHeaders={signed_headers}, Signature={signature}" + ), + ); + Signed { + request, + signature, + amz_date: date, + } +} + +fn sign(method: &str, target: &str, body: &[u8], extra: &[(&str, &str)]) -> Request { + sign_at( + method, + target, + body.to_vec(), + extra, + &sha256_hex(body), + now(), + ) + .request +} + +fn code(response: &Response) -> String { + let body = String::from_utf8_lossy(&response.body); + let start = body.find("").map(|i| i + 6).unwrap_or(0); + let end = body.find("").unwrap_or(0); + body[start..end].to_string() +} + +/// Builds a signed `aws-chunked` body chaining from `seed`, returning the framing +/// and the terminal chunk's signature. +fn signed_chunks(chunks: &[&[u8]], seed: &str, date: &str, trailer: Option<&str>) -> Vec { + let date_stamp = &date[..8]; + let scope = format!("{date_stamp}/us-east-1/s3/aws4_request"); + let key = signing_key(date_stamp); + let empty = sha256_hex(b""); + let mut previous = seed.to_string(); + let mut out = Vec::new(); + for chunk in chunks.iter().copied().chain([&b""[..]]) { + let to_sign = format!( + "AWS4-HMAC-SHA256-PAYLOAD\n{date}\n{scope}\n{previous}\n{empty}\n{}", + sha256_hex(chunk) + ); + previous = hex::encode(hmac_sha256(&key, to_sign.as_bytes())); + out.extend(format!("{:x};chunk-signature={previous}\r\n", chunk.len()).into_bytes()); + out.extend_from_slice(chunk); + out.extend_from_slice(b"\r\n"); + } + if let Some(trailer) = trailer { + out.truncate(out.len() - 2); + let to_sign = format!( + "AWS4-HMAC-SHA256-TRAILER\n{date}\n{scope}\n{previous}\n{}", + sha256_hex(format!("{trailer}\n").as_bytes()) + ); + let signature = hex::encode(hmac_sha256(&key, to_sign.as_bytes())); + out.extend( + format!("{trailer}\r\nx-amz-trailer-signature:{signature}\r\n\r\n").into_bytes(), + ); + } + out +} + +fn stored(store: &FileBucketStore, key: &str) -> Response { + route( + store, + &Request::new("GET", &format!("/data/{key}"), Vec::new()), + ) +} + +type Tamper = fn(&mut Vec); + +const STREAM_HEADERS: [(&str, &str); 2] = [ + ("content-encoding", "aws-chunked"), + ("x-amz-decoded-content-length", "11"), +]; + +fn streaming_put(target: &str, chunks: &[&[u8]], tamper: impl Fn(&mut Vec)) -> Request { + let mut signed = sign_at("PUT", target, Vec::new(), &STREAM_HEADERS, STREAMING, now()); + let mut body = signed_chunks(chunks, &signed.signature, &signed.amz_date, None); + tamper(&mut body); + signed.request.body = body; + signed.request +} + +#[test] +fn encoded_keys_verify_without_double_encoding() { + let store = store(); + let auth = auth(); + let target = "/data/dir/a%20b%2Bc%E3%81%82.txt"; + let put = sign("PUT", target, b"body", &[]); + assert_eq!(route_with_auth(&store, &put, &auth).status, 200); + let got = route_with_auth(&store, &sign("GET", target, b"", &[]), &auth); + assert_eq!(got.status, 200); + assert_eq!(got.body, b"body"); + assert_eq!(stored(&store, "dir/a%20b%2Bc%E3%81%82.txt").body, b"body"); +} + +#[test] +fn wrong_secret_and_tampering_are_rejected() { + let store = store(); + let mut other = auth(); + other.secret_access_key = "another".to_string(); + let put = sign("PUT", "/data/k", b"body", &[]); + let response = route_with_auth(&store, &put, &other); + assert_eq!(response.status, 403); + assert_eq!(code(&response), "SignatureDoesNotMatch"); + + let mut retargeted = sign("PUT", "/data/k", b"body", &[]); + retargeted.path = "/data/other".to_string(); + let response = route_with_auth(&store, &retargeted, &auth()); + assert_eq!(code(&response), "SignatureDoesNotMatch"); +} + +#[test] +fn stale_or_future_requests_are_rejected() { + let store = store(); + let auth = auth(); + for offset in [-901, 901] { + let signed = sign_at( + "GET", + "/", + Vec::new(), + &[], + &sha256_hex(b""), + now() + offset, + ); + let response = route_with_auth(&store, &signed.request, &auth); + assert_eq!(response.status, 403); + assert_eq!(code(&response), "RequestTimeTooSkewed"); + } + let tolerated = sign_at("GET", "/", Vec::new(), &[], &sha256_hex(b""), now() - 600); + assert_eq!( + route_with_auth(&store, &tolerated.request, &auth).status, + 200 + ); +} + +#[test] +fn malformed_dates_are_rejected_without_panicking() { + let store = store(); + let mut req = sign("GET", "/", b"", &[]); + req.headers + .insert("x-amz-date".to_string(), "ใ‚ใ‚ใ‚ใ‚ใ‚Z".to_string()); + assert_eq!("ใ‚ใ‚ใ‚ใ‚ใ‚Z".len(), 16); + assert_eq!(route_with_auth(&store, &req, &auth()).status, 403); + + let mut req = sign("GET", "/", b"", &[]); + let authorization = req.headers["authorization"].clone(); + let credential_date = amz_date(now())[..8].to_string(); + req.headers.insert( + "authorization".to_string(), + authorization.replace(&credential_date, "20000101"), + ); + assert_eq!( + code(&route_with_auth(&store, &req, &auth())), + "AuthorizationHeaderMalformed" + ); +} + +#[test] +fn host_and_behavioural_amz_headers_must_be_signed() { + let store = store(); + let auth = auth(); + let mut unsigned_header = sign("PUT", "/data/copy", b"", &[]); + unsigned_header + .headers + .insert("x-amz-copy-source".to_string(), "/data/secret".to_string()); + let response = route_with_auth(&store, &unsigned_header, &auth); + assert_eq!(response.status, 403); + assert_eq!(code(&response), "AccessDenied"); + + let mut user_agent = sign("GET", "/", b"", &[]); + user_agent + .headers + .insert("x-amz-user-agent".to_string(), "aws-sdk-js/3".to_string()); + assert_eq!(route_with_auth(&store, &user_agent, &auth).status, 200); + + let mut no_host = sign("GET", "/", b"", &[]); + let value = no_host.headers["authorization"].replace("SignedHeaders=host;", "SignedHeaders="); + no_host.headers.insert("authorization".to_string(), value); + assert_eq!( + code(&route_with_auth(&store, &no_host, &auth)), + "AccessDenied" + ); +} + +#[test] +fn content_sha256_header_may_be_left_out_of_signed_headers() { + let store = store(); + let auth = auth(); + let date = amz_date(now()); + let scope = format!("{}/us-east-1/s3/aws4_request", &date[..8]); + let hash = sha256_hex(b""); + let sign_with = |payload_hash: &str| { + let canonical = format!( + "GET\n/\n\nhost:s3.test:9000\nx-amz-date:{date}\n\nhost;x-amz-date\n{payload_hash}" + ); + let to_sign = format!( + "AWS4-HMAC-SHA256\n{date}\n{scope}\n{}", + sha256_hex(canonical.as_bytes()) + ); + hex::encode(hmac_sha256(&signing_key(&date[..8]), to_sign.as_bytes())) + }; + let request = |signature: &str, sent_hash: &str| { + let mut req = Request::new("GET", "/", Vec::new()); + req.headers + .insert("host".to_string(), "s3.test:9000".to_string()); + req.headers.insert("x-amz-date".to_string(), date.clone()); + req.headers + .insert("x-amz-content-sha256".to_string(), sent_hash.to_string()); + req.headers.insert( + "authorization".to_string(), + format!("AWS4-HMAC-SHA256 Credential=test-key/{scope}, SignedHeaders=host;x-amz-date, Signature={signature}"), + ); + req + }; + let valid = request(&sign_with(&hash), &hash); + assert_eq!(route_with_auth(&store, &valid, &auth).status, 200); + // Still bound by the signature, through the payload hash. + let swapped = request(&sign_with(&hash), "UNSIGNED-PAYLOAD"); + assert_eq!( + code(&route_with_auth(&store, &swapped, &auth)), + "SignatureDoesNotMatch" + ); +} + +#[test] +fn signed_streaming_upload_is_verified_and_decoded() { + let store = store(); + let auth = auth(); + let put = streaming_put("/data/stream.txt", &[b"hello ", b"world"], |_| {}); + assert_eq!(route_with_auth(&store, &put, &auth).status, 200); + let got = stored(&store, "stream.txt"); + assert_eq!(got.body, b"hello world"); + assert_eq!(got.headers.get("Content-Encoding"), None); +} + +#[test] +fn streaming_tampering_is_rejected() { + let store = store(); + let auth = auth(); + let cases: [(&str, Tamper); 2] = [ + ("flipped byte", |body| { + let at = body.windows(5).position(|w| w == b"hello").unwrap(); + body[at] = b'j'; + }), + ("corrupted signature", |body| { + let at = body + .windows(16) + .position(|w| w == b"chunk-signature=") + .unwrap() + + 16; + body[at] = if body[at] == b'0' { b'1' } else { b'0' }; + }), + ]; + for (name, tamper) in cases { + let put = streaming_put("/data/stream.txt", &[b"hello ", b"world"], tamper); + let response = route_with_auth(&store, &put, &auth); + assert_eq!(response.status, 403, "{name}"); + assert_eq!(code(&response), "SignatureDoesNotMatch", "{name}"); + } + + // Reordered chunks break the signature chain even though each one is intact. + let mut signed = sign_at( + "PUT", + "/data/stream.txt", + Vec::new(), + &STREAM_HEADERS, + STREAMING, + now(), + ); + let framed = signed_chunks( + &[b"hello ", b"world"], + &signed.signature, + &signed.amz_date, + None, + ); + let text = String::from_utf8(framed).unwrap(); + let parts: Vec<&str> = text.split_inclusive("\r\n").collect(); + signed.request.body = [parts[2], parts[3], parts[0], parts[1], parts[4], parts[5]] + .concat() + .into_bytes(); + assert_eq!( + code(&route_with_auth(&store, &signed.request, &auth)), + "SignatureDoesNotMatch" + ); + + // A signed payload may not silently drop its chunk signatures. + let mut signed = sign_at( + "PUT", + "/data/stream.txt", + Vec::new(), + &STREAM_HEADERS, + STREAMING, + now(), + ); + signed.request.body = b"b\r\nhello world\r\n0\r\n\r\n".to_vec(); + assert_eq!( + code(&route_with_auth(&store, &signed.request, &auth)), + "SignatureDoesNotMatch" + ); + assert_eq!(stored(&store, "stream.txt").status, 404); +} + +#[test] +fn signed_trailer_is_verified() { + let store = store(); + let auth = auth(); + let upload = |trailer_on_wire: &str| { + let mut signed = sign_at( + "PUT", + "/data/trailer.txt", + Vec::new(), + &[ + ("content-encoding", "aws-chunked"), + ("x-amz-decoded-content-length", "11"), + ("x-amz-trailer", "x-amz-checksum-crc32"), + ], + STREAMING_TRAILER, + now(), + ); + let framed = signed_chunks( + &[b"hello world"], + &signed.signature, + &signed.amz_date, + Some("x-amz-checksum-crc32:DQpIDQ=="), + ); + let text = String::from_utf8(framed).unwrap(); + signed.request.body = text + .replace("x-amz-checksum-crc32:DQpIDQ==", trailer_on_wire) + .into_bytes(); + route_with_auth(&store, &signed.request, &auth) + }; + assert_eq!( + code(&upload("x-amz-checksum-crc32:AAAAAA==")), + "SignatureDoesNotMatch" + ); + assert_eq!(upload("x-amz-checksum-crc32:DQpIDQ==").status, 200); + assert_eq!(stored(&store, "trailer.txt").body, b"hello world"); +} + +#[test] +fn unsigned_trailer_upload_is_decoded_in_strict_mode() { + let store = store(); + let mut signed = sign_at( + "PUT", + "/data/unsigned.txt", + Vec::new(), + &STREAM_HEADERS, + UNSIGNED_TRAILER, + now(), + ); + signed.request.body = + b"b\r\nhello world\r\n0\r\nx-amz-checksum-crc32:DQpIDQ==\r\n\r\n".to_vec(); + assert_eq!( + route_with_auth(&store, &signed.request, &auth()).status, + 200 + ); + assert_eq!(stored(&store, "unsigned.txt").body, b"hello world"); +} + +#[test] +fn unsupported_streaming_algorithm_is_not_implemented_in_strict_mode() { + let store = store(); + let signed = sign_at( + "PUT", + "/data/k", + b"0\r\n\r\n".to_vec(), + &[], + "STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD", + now(), + ); + assert_eq!( + route_with_auth(&store, &signed.request, &auth()).status, + 501 + ); +} + +#[test] +fn relaxed_mode_decodes_framing_instead_of_storing_it() { + let store = store(); + let mut put = Request::new( + "PUT", + "/data/relaxed.txt", + b"5;chunk-signature=ignored\r\nhello\r\n0;chunk-signature=ignored\r\n\r\n".to_vec(), + ); + put.headers + .insert("x-amz-content-sha256".to_string(), STREAMING.to_string()); + put.headers.insert( + "content-encoding".to_string(), + "gzip,aws-chunked".to_string(), + ); + assert_eq!(route(&store, &put).status, 200); + let got = stored(&store, "relaxed.txt"); + assert_eq!(got.body, b"hello"); + assert_eq!(got.headers.get("Content-Encoding").unwrap(), "gzip"); +} + +#[test] +fn malformed_or_short_streaming_bodies_are_rejected() { + let store = store(); + for (body, decoded_length) in [ + (&b"5\r\nhe"[..], "2"), + (b"plain", "5"), + (b"5\r\nhello\r\n0\r\n\r\n", "6"), + ] { + let mut put = Request::new("PUT", "/data/bad.txt", body.to_vec()); + put.headers.insert( + "x-amz-content-sha256".to_string(), + UNSIGNED_TRAILER.to_string(), + ); + put.headers.insert( + "x-amz-decoded-content-length".to_string(), + decoded_length.to_string(), + ); + let response = route(&store, &put); + assert_eq!(response.status, 400); + assert_eq!(code(&response), "IncompleteBody"); + } + assert_eq!(stored(&store, "bad.txt").status, 404); +} + +fn presign(path: &str, signed_at: i64, expires: &str) -> Request { + let date = amz_date(signed_at); + let date_stamp = &date[..8]; + let scope = format!("{date_stamp}/us-east-1/s3/aws4_request"); + let credential = format!("test-key/{scope}"); + let query = format!( + "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential={}&X-Amz-Date={date}&X-Amz-Expires={expires}&X-Amz-SignedHeaders=host", + aws_percent_encode(&credential, "") + ); + let canonical_request = + format!("GET\n{path}\n{query}\nhost:s3.test:9000\n\nhost\nUNSIGNED-PAYLOAD"); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{date}\n{scope}\n{}", + sha256_hex(canonical_request.as_bytes()) + ); + let signature = hex::encode(hmac_sha256( + &signing_key(date_stamp), + string_to_sign.as_bytes(), + )); + let mut request = Request::new( + "GET", + &format!("{path}?{query}&X-Amz-Signature={signature}"), + Vec::new(), + ); + request + .headers + .insert("host".to_string(), "s3.test:9000".to_string()); + request +} + +#[test] +fn presigned_urls_enforce_signature_and_time_window() { + let store = store(); + let auth = auth(); + let put = sign("PUT", "/data/a%20b.txt", b"body", &[]); + assert_eq!(route_with_auth(&store, &put, &auth).status, 200); + + let valid = route_with_auth(&store, &presign("/data/a%20b.txt", now(), "300"), &auth); + assert_eq!(valid.status, 200); + assert_eq!(valid.body, b"body"); + + let expired = presign("/data/a%20b.txt", now() - 400, "300"); + assert_eq!(route_with_auth(&store, &expired, &auth).status, 403); + let future = presign("/data/a%20b.txt", now() + 1000, "300"); + assert_eq!(route_with_auth(&store, &future, &auth).status, 403); + + let mut tampered = presign("/data/a%20b.txt", now(), "300"); + tampered + .query + .insert("X-Amz-Expires".to_string(), "600".to_string()); + assert_eq!( + code(&route_with_auth(&store, &tampered, &auth)), + "SignatureDoesNotMatch" + ); + + let mut retargeted = presign("/data/a%20b.txt", now(), "300"); + retargeted.path = "/data/other.txt".to_string(); + assert_eq!( + code(&route_with_auth(&store, &retargeted, &auth)), + "SignatureDoesNotMatch" + ); + + let mut extra = presign("/data/a%20b.txt", now(), "300"); + extra + .headers + .insert("x-amz-copy-source".to_string(), "/data/secret".to_string()); + assert_eq!( + code(&route_with_auth(&store, &extra, &auth)), + "AccessDenied" + ); +} diff --git a/web/s3-console/index.html b/web/s3-console/index.html new file mode 100644 index 0000000..fb3b85e --- /dev/null +++ b/web/s3-console/index.html @@ -0,0 +1,12 @@ + + + + + + devcloud S3 console + + +
+ + + diff --git a/web/s3-console/package-lock.json b/web/s3-console/package-lock.json new file mode 100644 index 0000000..21669c3 --- /dev/null +++ b/web/s3-console/package-lock.json @@ -0,0 +1,1882 @@ +{ + "name": "@devcloud/s3-console", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@devcloud/s3-console", + "version": "0.0.1", + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "22.10.7", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.7.0", + "typescript": "5.9.3", + "vite": "6.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz", + "integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-compilation-targets": "^7.29.13", + "@babel/helper-module-transforms": "^7.29.13", + "@babel/helpers": "^7.29.13", + "@babel/parser": "^7.29.13", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz", + "integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.13", + "@babel/types": "^7.29.11", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz", + "integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz", + "integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.13" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz", + "integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz", + "integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.13", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.5.tgz", + "integrity": "sha512-FdWPeulkH+FH+rBzyKBD59tuVN4tRKvmVilSJm0OcnoYsQTpd9UhD78OaPcsYqo2fX6hCwAG0YIjrDLoQ3pR3g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.5.tgz", + "integrity": "sha512-bmkDuBGnF+Ji8atWlXcNjxIC52ShCbdXPbW7Nz0lRoCFi1lrvsO0XobJY36kTj7duc1vOmGJeQ9vtVuGjnzihA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.5.tgz", + "integrity": "sha512-6KvVfnqPTqmbQ5YobMl9HYiSymAsCt8HlNeriDshwJQcveXwT+vIMW6wGL1RcJbXH9+r3BAJvjab5+rIgtSEqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.5.tgz", + "integrity": "sha512-SNamgdrY69oPdBrQV9fKGo6PSmKcVhyn0D0jnDmcqgRJvrxQGGBhtekOHTUJ7TCgkW0nE8XMH9v2CwXmjNIzVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.5.tgz", + "integrity": "sha512-HNdl34thiJtHDSglogjmAlQ296L8cc8zA7ODH65nnmY7E+jPP55WEbh8J5583rWFLMjxyYywxZuwPOwRvEi5tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.5.tgz", + "integrity": "sha512-WvZYtANNswfHzSNHaJAoG6Ipfc4SyZIDYuIjFdHv07xXxhHl+Xx8o+bNDWsxpOADFT2mKXcXVrfOG4OlWTLo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.5.tgz", + "integrity": "sha512-EUU+Wgf9NyHekceC+DxAdm42f6bq2ezH/7DEb/JnCb8SAUhG2xph5BiVukxplWal2m5pP8CzJZE+FLwRopywjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.5.tgz", + "integrity": "sha512-dgU1CvUuE2IkaJBAeuAbxpDdMHAdZBT5oth5u22DtuoMg4gXXYr2+Sm30RigPJG1F2VEoiGJUziGLuIh9+jPxA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.5.tgz", + "integrity": "sha512-rdgJcJig2VngYlcTCRQw2BA8kLWM6QwDFe9uLFo81lya0jPC1r8wZu92sAvTUIv48GWlkRVi6/fpsa5CoFSKoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.5.tgz", + "integrity": "sha512-+jtCiQnrEFHWAUAcUpP7wppb1p6HKOgBP3mXx75cEYpDF2Y5MEqBky2HNz+osFuVx5uX44gnMTmbe5RJauzn6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.5.tgz", + "integrity": "sha512-heflX7ENhL8eHmLcUxDEHCSK8CKLyPprysb2JGoSTh5Ip7nHrzIRc+2USyxul3nxi98H8pGxJIEDEpURlS1X5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.5.tgz", + "integrity": "sha512-rRtNU0zBfRHkJ8pscHN9qRtUYLq8Rao4EX7aVWvKcFmJUANkCCXFRU7ovNBEMhpEONyUEB/3DRVDXwDDguSfHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.5.tgz", + "integrity": "sha512-/6auyi9yj8MD/Q+8P+f2ay57jQ7a0j7Yp1JK86pecH6ZnRViQZ5GOjoRSjGArFeTSHFznRbM2QPQcBgDPyNMfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.5.tgz", + "integrity": "sha512-Hsc0vlt+vPvqzUkHKffWKf3YE/yexrY+yeFf7IsUvDC6PV7YGCsA/gJvPa+WJdgxGJnwuoO1HmLkbahaLKabYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.5.tgz", + "integrity": "sha512-Jx5TMMSPJwHY7F/JwPdS6nuBwny2ECRK+EsAJV5HZrfiddeSS6ZrohkLkFZm3rjqBUuQ4FPX5gGMtakUuQO6fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.5.tgz", + "integrity": "sha512-fGWcq2lo/XCqAkBLOokg66D/z+MUouryB8i+QdU9RR6O5knG7rucc0WFPqWYL1Wk/wuiJouDfmyBxmZKRM9bag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.5.tgz", + "integrity": "sha512-JwO/Ys3jybc0UW3V5XO2CMnp0RhvoczDDQTgAEE1IctT+HRY3W5parIIWLUpmAK4gHSeeYJofm2iQPFA62K6ag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.5.tgz", + "integrity": "sha512-vVD4iqel6J6A1xAJlb4mxEO9i1wOJ4/SkcKoDKAuK/wqEBPOA7/P5dDyZvNx46ocHwOUwzo3WjhTdKqET9kEAg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.5.tgz", + "integrity": "sha512-HmEfNh5Cdd/NHxgqwMzKnjlvicQQA+D+w+vQXDJSV0x/aHiQSjVwh1Th5RoA7QsMieIaxibt/5iJq68BraTSUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.5.tgz", + "integrity": "sha512-NfmALpbe009nYzZ2QqlSW6nzbGddXpqk3nwualrIr+4QflQCTGMzt3QKK20yARiAnSV9fJdM5WNwIYUf31x3uA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.5.tgz", + "integrity": "sha512-ngcE7Udpy/iJ3lexVhYcbpUV2OJIakRGus3snc6K52BnAAJetDBG1Ku5Eh3CECtnW3mpBYWg5VCD4tTaOXKOVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.5.tgz", + "integrity": "sha512-DZ01A2EdTYX8xVeGoGiWkJBRWDh+/BOflwGPVsBay43PDxcq8ez0xuovZnPnk1xjE3CdI0pdZXxsJp/Xjmf7uQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.5.tgz", + "integrity": "sha512-nJKiMKDO140DUCEcQZl+f4HiGPMBxrW2webjuWJ2E3qPeoruHf2zByml4TP52lA1zDi6L4I92D/5stxeA/6pig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.5.tgz", + "integrity": "sha512-ykLkBCf+Kls24QmqM3IRf1T4sGMh0rXPyymtj+hXlWKPJnXbXraHqUUsKtOIxqLasjT1i7DhfQGAaZYeiHbSOw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.5.tgz", + "integrity": "sha512-NiFPUFw8cHhkmBlL1k1eaz+5AXXm89rztj+DQdDuQ7VGPM0+ADFhezQ2KcBMF50UEFzGUr0+DVjVND743XwKWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.5.tgz", + "integrity": "sha512-Khj/vrKCpdbiAWYbJAeSy4D5iep+qwW/86lNoKvdleJYGxgbRE7h87+5d5T9D7gt8NcqhNFpreRgIg8T4bLzKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", + "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.35.tgz", + "integrity": "sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001822", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001822.tgz", + "integrity": "sha512-Y3fvWQWzc69dlCyr07tuNrEhotv+db5FpMSPD3slZnkPKwRNOKwFBz2dP966XUmWMXLTOt+YShkFoQoom3U7hg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.477", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.477.tgz", + "integrity": "sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.5.tgz", + "integrity": "sha512-3Ve5VH1hacCVutdpbmu/htg4kz+JCOIWgy4Q3hjHPYGHeHeIElNHJ0LcJ3rCfIsd5G7qOl5k5mwpzGMj9kdHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.67.5", + "@rollup/rollup-android-arm64": "4.67.5", + "@rollup/rollup-android-x64": "4.67.5", + "@rollup/rollup-darwin-arm64": "4.67.5", + "@rollup/rollup-darwin-x64": "4.67.5", + "@rollup/rollup-freebsd-arm64": "4.67.5", + "@rollup/rollup-freebsd-x64": "4.67.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.5", + "@rollup/rollup-linux-arm-musleabihf": "4.67.5", + "@rollup/rollup-linux-arm64-gnu": "4.67.5", + "@rollup/rollup-linux-arm64-musl": "4.67.5", + "@rollup/rollup-linux-loong64-gnu": "4.67.5", + "@rollup/rollup-linux-loong64-musl": "4.67.5", + "@rollup/rollup-linux-ppc64-gnu": "4.67.5", + "@rollup/rollup-linux-ppc64-musl": "4.67.5", + "@rollup/rollup-linux-riscv64-gnu": "4.67.5", + "@rollup/rollup-linux-riscv64-musl": "4.67.5", + "@rollup/rollup-linux-s390x-gnu": "4.67.5", + "@rollup/rollup-linux-x64-gnu": "4.67.5", + "@rollup/rollup-linux-x64-musl": "4.67.5", + "@rollup/rollup-openbsd-x64": "4.67.5", + "@rollup/rollup-openharmony-arm64": "4.67.5", + "@rollup/rollup-win32-arm64-msvc": "4.67.5", + "@rollup/rollup-win32-ia32-msvc": "4.67.5", + "@rollup/rollup-win32-x64-gnu": "4.67.5", + "@rollup/rollup-win32-x64-msvc": "4.67.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/s3-console/package.json b/web/s3-console/package.json new file mode 100644 index 0000000..6927caa --- /dev/null +++ b/web/s3-console/package.json @@ -0,0 +1,23 @@ +{ + "name": "@devcloud/s3-console", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "dev": "vite", + "typecheck": "tsc -b" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.7.0", + "typescript": "5.9.3", + "vite": "6.4.2", + "@types/node": "22.10.7", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5" + } +} diff --git a/web/s3-console/src/App.tsx b/web/s3-console/src/App.tsx new file mode 100644 index 0000000..ae8d084 --- /dev/null +++ b/web/s3-console/src/App.tsx @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react' +import { + createBucket, + deleteBucket, + deleteObject, + getInfo, + listBuckets, + listObjects, + MAX_UPLOAD_BYTES, + objectUrl, + uploadObject, + type Bucket, + type Info, + type S3Object, +} from './api' +import { baseName, formatDate, formatSize, groupByFolder } from './format' + +function readLocation() { + const params = new URLSearchParams(window.location.search) + return { bucket: params.get('bucket') ?? '', prefix: params.get('prefix') ?? '' } +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : 'Request failed' +} + +export function App() { + const [info, setInfo] = useState(null) + const [buckets, setBuckets] = useState([]) + const [objects, setObjects] = useState([]) + const [location, setLocation] = useState(readLocation) + const [newBucket, setNewBucket] = useState('') + const [pendingDelete, setPendingDelete] = useState(null) + const [error, setError] = useState('') + const [status, setStatus] = useState('') + const [busy, setBusy] = useState(false) + const fileInput = useRef(null) + const { bucket, prefix } = location + const shownLocation = useRef(location) + shownLocation.current = location + const latestListing = useRef(0) + + const navigate = useCallback((next: { bucket: string; prefix: string }) => { + const params = new URLSearchParams() + if (next.bucket) params.set('bucket', next.bucket) + if (next.prefix) params.set('prefix', next.prefix) + const query = params.toString() + window.history.pushState(null, '', query ? `/?${query}` : '/') + setPendingDelete(null) + setLocation(next) + }, []) + + useEffect(() => { + const onPopState = () => { + setPendingDelete(null) + setLocation(readLocation()) + } + window.addEventListener('popstate', onPopState) + return () => window.removeEventListener('popstate', onPopState) + }, []) + + const refreshBuckets = useCallback(async () => { + setBuckets(await listBuckets()) + }, []) + + // Delete acts on the bucket on screen, so a listing from any other location or + // an older request must never be shown under it. + const refreshObjects = useCallback(async () => { + if (shownLocation.current.bucket !== bucket || shownLocation.current.prefix !== prefix) return + const request = ++latestListing.current + try { + const next = bucket ? await listObjects(bucket, prefix) : [] + if (request === latestListing.current) setObjects(next) + } catch (e) { + if (request === latestListing.current) throw e + } + }, [bucket, prefix]) + + useEffect(() => { + getInfo().then(setInfo, (e) => setError(message(e))) + refreshBuckets().catch((e) => setError(message(e))) + }, [refreshBuckets]) + + useEffect(() => { + setError('') + setObjects([]) + refreshObjects().catch((e) => { + setObjects([]) + setError(message(e)) + }) + }, [refreshObjects]) + + const run = useCallback(async (done: string, action: () => Promise) => { + setBusy(true) + setError('') + setStatus('') + try { + await action() + setStatus(done) + } catch (e) { + setError(message(e)) + } finally { + setBusy(false) + setPendingDelete(null) + } + }, []) + + const submitBucket = (event: FormEvent) => { + event.preventDefault() + const name = newBucket.trim() + if (!name) return + void run(`Created bucket ${name}`, async () => { + await createBucket(name) + setNewBucket('') + await refreshBuckets() + navigate({ bucket: name, prefix: '' }) + }) + } + + const removeBucket = () => + run(`Deleted bucket ${bucket}`, async () => { + await deleteBucket(bucket) + navigate({ bucket: '', prefix: '' }) + await refreshBuckets() + }) + + const removeObject = (key: string) => + run(`Deleted ${key}`, async () => { + await deleteObject(bucket, key) + await Promise.all([refreshObjects(), refreshBuckets()]) + }) + + const upload = (files: FileList) => { + const selected = [...files] + const tooLarge = selected.find((file) => file.size > MAX_UPLOAD_BYTES) + if (tooLarge) { + setError(`${tooLarge.name} exceeds the ${formatSize(MAX_UPLOAD_BYTES)} request limit.`) + return + } + void run(`Uploaded ${selected.length} file(s)`, async () => { + for (const file of selected) await uploadObject(bucket, prefix + file.name, file) + await Promise.all([refreshObjects(), refreshBuckets()]) + }) + } + + const listing = useMemo(() => groupByFolder(objects, prefix), [objects, prefix]) + const crumbs = useMemo(() => { + const parts = prefix.split('/').filter(Boolean) + return parts.map((part, index) => ({ label: part, prefix: `${parts.slice(0, index + 1).join('/')}/` })) + }, [prefix]) + const isEmpty = listing.folders.length === 0 && listing.objects.length === 0 + + return ( +
+
+

devcloud S3 console

+ {info && ( + + {info.region} ยท {info.authMode} auth + + )} +
+
+ +
+
+ {error &&

{error}

} + {!error && status &&

{status}

} +
+ {!bucket &&

Select or create a bucket.

} + {bucket && ( + <> +
+ +
+ { + if (event.target.files?.length) upload(event.target.files) + event.target.value = '' + }} + /> + + {pendingDelete === bucket ? ( + <> + + + + ) : ( + + )} +
+
+ {isEmpty ? ( +

This location is empty.

+ ) : ( + + + + + + + + + + + + {listing.folders.map((folder) => ( + + + + ))} + {listing.objects.map((object) => ( + + + + + + + + ))} + +
NameSizeTypeLast modifiedActions
+ +
{baseName(object.key)}{formatSize(object.size)}{object.contentType}{formatDate(object.lastModified)} + {pendingDelete === object.key ? ( + <> + + + + ) : ( + <> + Download + + + )} +
+ )} + + )} +
+
+
+ ) +} diff --git a/web/s3-console/src/api.ts b/web/s3-console/src/api.ts new file mode 100644 index 0000000..2f9f997 --- /dev/null +++ b/web/s3-console/src/api.ts @@ -0,0 +1,67 @@ +export interface Info { + authMode: 'relaxed' | 'strict' + region: string +} + +export interface Bucket { + name: string + creationDate: string + objectCount: number +} + +export interface S3Object { + key: string + size: number + etag: string + contentType: string + lastModified: string +} + +/** Mirrors the server's request body limit (`MAX_BODY_BYTES`). */ +export const MAX_UPLOAD_BYTES = 128 * 1024 * 1024 + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, init) + if (!response.ok) { + const body = await response.json().catch(() => null) as { error?: string } | null + throw new Error(body?.error ?? `Request failed with status ${response.status}`) + } + return (response.status === 204 || !response.headers.get('content-type')?.includes('json') + ? undefined + : await response.json()) as T +} + +function bucketPath(bucket: string): string { + return `/api/buckets/${encodeURIComponent(bucket)}` +} + +/** The key is a query value because URL parsing resolves `.` and `..` path segments. */ +export function objectUrl(bucket: string, key: string): string { + return `${bucketPath(bucket)}/object?key=${encodeURIComponent(key)}` +} + +export const getInfo = () => request('/api/info') + +export const listBuckets = async () => + (await request<{ buckets: Bucket[] }>('/api/buckets')).buckets + +export const createBucket = (bucket: string) => + request(bucketPath(bucket), { method: 'PUT' }) + +export const deleteBucket = (bucket: string) => + request(bucketPath(bucket), { method: 'DELETE' }) + +export const listObjects = async (bucket: string, prefix: string) => + (await request<{ objects: S3Object[] }>( + `${bucketPath(bucket)}/objects?prefix=${encodeURIComponent(prefix)}`, + )).objects + +export const uploadObject = (bucket: string, key: string, file: File) => + request(objectUrl(bucket, key), { + method: 'PUT', + body: file, + headers: file.type ? { 'Content-Type': file.type } : undefined, + }) + +export const deleteObject = (bucket: string, key: string) => + request(objectUrl(bucket, key), { method: 'DELETE' }) diff --git a/web/s3-console/src/format.ts b/web/s3-console/src/format.ts new file mode 100644 index 0000000..5670a86 --- /dev/null +++ b/web/s3-console/src/format.ts @@ -0,0 +1,40 @@ +import type { S3Object } from './api' + +export interface Listing { + folders: string[] + objects: S3Object[] +} + +/** Groups a flat prefix listing into the immediate folders and objects below `prefix`. */ +export function groupByFolder(objects: S3Object[], prefix: string): Listing { + const folders = new Set() + const direct: S3Object[] = [] + for (const object of objects) { + const slash = object.key.indexOf('/', prefix.length) + if (slash === -1) direct.push(object) + else folders.add(object.key.slice(0, slash + 1)) + } + return { folders: [...folders].sort(), objects: direct } +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KiB', 'MiB', 'GiB', 'TiB'] + let value = bytes + let unit = -1 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + return `${value.toFixed(1)} ${units[unit]}` +} + +export function formatDate(value: string): string { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString() +} + +export function baseName(path: string): string { + const trimmed = path.endsWith('/') ? path.slice(0, -1) : path + return trimmed.slice(trimmed.lastIndexOf('/') + 1) +} diff --git a/web/s3-console/src/main.tsx b/web/s3-console/src/main.tsx new file mode 100644 index 0000000..d010459 --- /dev/null +++ b/web/s3-console/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles/tokens.css' +import './styles/globals.css' + +const root = document.getElementById('root') +if (!root) throw new Error('Console root element is missing') +createRoot(root).render() diff --git a/web/s3-console/src/styles/globals.css b/web/s3-console/src/styles/globals.css new file mode 100644 index 0000000..fa8f5bc --- /dev/null +++ b/web/s3-console/src/styles/globals.css @@ -0,0 +1,135 @@ +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; } +body { + background: var(--surface-base); + color: var(--text-primary); + font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif; +} +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; } +[hidden] { display: none !important; } +:is(button, input, a):focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.app { display: flex; flex-direction: column; height: 100%; } +.topbar { + display: flex; + align-items: baseline; + gap: 12px; + padding: 10px 16px; + background: var(--surface-panel); + border-bottom: 1px solid var(--border-default); +} +.topbar h1 { margin: 0; font-size: 15px; } +.meta { color: var(--text-secondary); } +.layout { display: flex; flex: 1; min-height: 0; } +.sidebar { + width: 280px; + flex-shrink: 0; + overflow-y: auto; + padding: 12px; + background: var(--surface-panel-muted); + border-right: 1px solid var(--border-default); +} +.new-bucket label { display: block; margin-bottom: 4px; font-weight: 600; } +.row { display: flex; align-items: center; gap: 6px; } +.new-bucket input { + flex: 1; + min-width: 0; + min-height: 32px; + padding: 5px 8px; + color: var(--text-primary); + background: var(--surface-panel); + border: 1px solid var(--border-input); + border-radius: var(--radius-md); +} +.bucket-list { margin: 12px 0 0; padding: 0; list-style: none; } +.bucket { + display: flex; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 7px 8px; + color: inherit; + text-align: left; + background: none; + border: 1px solid transparent; + border-radius: var(--radius-md); +} +.bucket:hover { background: var(--surface-subtle); } +.bucket.active { background: var(--surface-active); border-color: var(--border-active); font-weight: 600; } +.bucket-name { overflow-wrap: anywhere; } +.empty-note { margin: 12px 8px; } +.content { flex: 1; min-width: 0; overflow: auto; padding: 16px; } +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} +.crumbs { font-size: 14px; overflow-wrap: anywhere; } +.separator { margin: 0 4px; color: var(--text-secondary); } +.link { + padding: 0; + color: var(--accent-object); + text-align: left; + overflow-wrap: anywhere; + background: none; + border: 0; +} +.link:hover { text-decoration: underline; } +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 5px 10px; + color: var(--text-primary); + font-weight: 600; + text-decoration: none; + white-space: nowrap; + background: var(--surface-panel); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); +} +.button:hover:not(:disabled) { background: var(--surface-subtle); } +.button:disabled { opacity: 0.58; } +.button.danger { color: var(--accent-danger); border-color: var(--border-danger); } +.button.primary, .button.primary:hover:not(:disabled) { color: var(--accent-on-fill); background: var(--accent-primary); border-color: var(--accent-primary); } +.button.danger.primary, .button.danger.primary:hover:not(:disabled) { color: var(--accent-on-fill); background: var(--accent-danger); border-color: var(--accent-danger); } +.objects { + width: 100%; + border-collapse: collapse; + background: var(--surface-panel); + border: 1px solid var(--border-default); +} +.objects th, .objects td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border-default); } +.objects th { color: var(--text-secondary); font-weight: 600; } +.objects .numeric { text-align: right; white-space: nowrap; } +.objects .name { overflow-wrap: anywhere; } +.objects .actions { display: flex; justify-content: flex-end; gap: 6px; } +.placeholder { margin: 32px 0; color: var(--text-secondary); text-align: center; } +.banner { + margin: 0 0 12px; + padding: 7px 10px; + background: var(--surface-success-soft); + border-radius: var(--radius-md); +} +.banner.error { color: var(--accent-danger); background: var(--surface-error-soft); } +@media (max-width: 720px) { + .layout { flex-direction: column; } + .sidebar { width: 100%; max-height: 40%; border-right: 0; border-bottom: 1px solid var(--border-default); } +} diff --git a/web/s3-console/src/styles/tokens.css b/web/s3-console/src/styles/tokens.css new file mode 100644 index 0000000..ebd8332 --- /dev/null +++ b/web/s3-console/src/styles/tokens.css @@ -0,0 +1,63 @@ +:root { + color-scheme: light; + --surface-base: #f7f8f5; + --surface-panel: #ffffff; + --surface-panel-muted: #fbfcf9; + --surface-subtle: #eef1ec; + --surface-active: #f3faf6; + --surface-overlay: rgba(255, 255, 255, 0.86); + --surface-scrim: rgba(16, 21, 17, 0.32); + --surface-success-soft: #ddefe7; + --surface-warning-soft: #f4ece2; + --surface-error-soft: #f8e7e3; + --surface-danger-soft: #fff8f6; + --surface-segment-active: #e7f3ec; + --text-primary: #1d211c; + --text-secondary: #5f675d; + --border-default: #d9ded5; + --border-active: #9fb6a7; + --border-input: #7e897b; + --border-empty: #c8d1c2; + --border-danger: #e3b8b3; + --border-danger-soft: #e0b4ab; + --accent-primary: #176b4d; + --accent-object: #245b8f; + --accent-warning: #9a5b13; + --accent-danger: #b42318; + --accent-on-fill: #ffffff; + --code-background: #101511; + --code-text: #e8efe7; + --radius-md: 8px; + --shadow-panel: 0 1px 2px rgba(20, 28, 18, 0.05), 0 14px 34px rgba(20, 28, 18, 0.08); + --focus-ring-color: #cfe5d9; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --surface-base: #121613; + --surface-panel: #181d19; + --surface-panel-muted: #1e2420; + --surface-subtle: #242b26; + --surface-active: #223329; + --surface-segment-active: #263d31; + --surface-error-soft: #361d1b; + --surface-warning-soft: #30281e; + --surface-scrim: rgba(0, 0, 0, 0.6); + --text-primary: #e8efe7; + --text-secondary: #9ba398; + --border-default: #2f3831; + --border-active: #44634f; + --border-input: #7a897e; + --border-empty: #44634f; + --border-danger: #5c2824; + --accent-primary: #3eb885; + --accent-object: #64a0dc; + --accent-warning: #e6b56e; + --accent-danger: #f87171; + --accent-on-fill: #181d19; + --focus-ring-color: #24583e; + --code-background: #0d110e; + --code-text: #e8efe7; + } +} diff --git a/web/s3-console/tsconfig.app.json b/web/s3-console/tsconfig.app.json new file mode 100644 index 0000000..8fcfef4 --- /dev/null +++ b/web/s3-console/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/web/s3-console/tsconfig.json b/web/s3-console/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/s3-console/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/s3-console/tsconfig.node.json b/web/s3-console/tsconfig.node.json new file mode 100644 index 0000000..534919d --- /dev/null +++ b/web/s3-console/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/web/s3-console/vite.config.ts b/web/s3-console/vite.config.ts new file mode 100644 index 0000000..bf89b7a --- /dev/null +++ b/web/s3-console/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +const consoleOrigin = process.env.DEVCLOUD_S3_CONSOLE_ORIGIN ?? 'http://127.0.0.1:9001' + +export default defineConfig({ + base: '/', + build: { + emptyOutDir: true, + outDir: '../../services/s3/assets/console', + }, + plugins: [react()], + server: { + proxy: { + '/api': consoleOrigin, + }, + }, +}) From 51fdbf484a556a15dbc34c9463b91e11f9c7d953 Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 6 Nov 2026 12:06:11 +0900 Subject: [PATCH 2/6] test(s3): add end-to-end suite against the Docker image Builds the standalone image and exercises it through real sockets, Chromium, and the AWS SDK for JavaScript: CORS from a browser, signed requests, the web console, image startup, and the existing S3 acceptance gate. --- services/s3/README.md | 7 + web/s3-console/e2e/demo.spec.ts | 74 +++++ web/s3-console/e2e/harness.ts | 402 +++++++++++++++++++++++ web/s3-console/e2e/image.spec.ts | 75 +++++ web/s3-console/e2e/usecases.ts | 485 +++++++++++++++++++++++++++ web/s3-console/package-lock.json | 493 ++++++++++++++++++++++++++++ web/s3-console/package.json | 14 +- web/s3-console/playwright.config.ts | 10 + 8 files changed, 1555 insertions(+), 5 deletions(-) create mode 100644 web/s3-console/e2e/demo.spec.ts create mode 100644 web/s3-console/e2e/harness.ts create mode 100644 web/s3-console/e2e/image.spec.ts create mode 100644 web/s3-console/e2e/usecases.ts create mode 100644 web/s3-console/playwright.config.ts diff --git a/services/s3/README.md b/services/s3/README.md index ef14dc5..ddaaaac 100644 --- a/services/s3/README.md +++ b/services/s3/README.md @@ -89,3 +89,10 @@ like API calls. The UI source is `web/s3-console`. `npm run build` there writes `services/s3/assets/console`, which is committed and embedded into the binary. + +## End-to-end tests + +`npm run test:e2e` in `web/s3-console` builds this image and tests it through real +sockets, a real browser, and the AWS SDK for JavaScript. It needs Docker and +Playwright's Chromium (`npx playwright install chromium`), and writes under +`web/s3-console/dist/e2e`. diff --git a/web/s3-console/e2e/demo.spec.ts b/web/s3-console/e2e/demo.spec.ts new file mode 100644 index 0000000..a99420b --- /dev/null +++ b/web/s3-console/e2e/demo.spec.ts @@ -0,0 +1,74 @@ +// Walks every use case in one continuous page recording, for the release demo. +import * as fs from 'node:fs/promises' +import { test } from '@playwright/test' +import { + OUT, + Recorder, + buildImage, + openRecordedPage, + removeImage, + saveVideo, + serveWebOrigin, + port, +} from './harness' +import { + browserUsesCors, + consoleManagesObjects, + existingGateStillPasses, + imageServesBothPorts, + sdkSignsOverSocket, +} from './usecases' + +test('demo walks every use case', async ({ browser }) => { + await buildImage() + const { context, page, clockStart } = await openRecordedPage(browser) + const panel = await serveWebOrigin(port('panel')) + const recorders: Recorder[] = [] + + const run = async (useCase: string, title: string, flow: (recorder: Recorder) => Promise) => { + await page.goto(panel.url) + await page.evaluate((text) => { + const heading = document.getElementById('title') + if (heading) heading.textContent = text + }, `${useCase}: ${title}`) + await page.waitForTimeout(1_500) + const recorder = new Recorder(useCase, page, clockStart) + recorders.push(recorder) + await flow(recorder) + recorder.endedAt = recorder.now() + } + + let video + try { + await run('UC-001', 'CORS from a browser', (recorder) => browserUsesCors(recorder, page)) + await run('UC-002', 'Signed requests from an SDK', sdkSignsOverSocket) + await run('UC-003', 'Web console', (recorder) => consoleManagesObjects(recorder, page)) + await run('UC-004', 'Starting the image', imageServesBothPorts) + await run('UC-005', 'Existing S3 acceptance gate', existingGateStillPasses) + } finally { + video = await saveVideo(context, page, 'demo') + await panel.close() + await removeImage() + } + + await fs.mkdir(`${OUT}/demo`, { recursive: true }) + await fs.copyFile(video.file, `${OUT}/demo/screen.webm`) + await fs.writeFile(`${OUT}/demo/demo.json`, JSON.stringify({ + run_token: process.env.VERIFY_SCAN_RUN_TOKEN ?? 'local', + video: `${OUT}/demo/screen.webm`, + use_cases: recorders.map((recorder) => ({ + use_case: recorder.useCase, + correlation_id: recorder.correlationId, + systems: recorder.systems, + start_s: recorder.startedAt, + end_s: Math.min(recorder.endedAt, video.duration), + checkpoints: recorder.checkpoints.map(({ id, correlation_id, actual, interval }) => ({ + id, + correlation_id, + actual, + start_s: interval?.[0] ?? recorder.startedAt, + end_s: Math.min(interval?.[1] ?? recorder.endedAt, video.duration), + })), + })), + }, null, 2)) +}) diff --git a/web/s3-console/e2e/harness.ts b/web/s3-console/e2e/harness.ts new file mode 100644 index 0000000..5d8dc2f --- /dev/null +++ b/web/s3-console/e2e/harness.ts @@ -0,0 +1,402 @@ +// Runs the standalone S3 image in Docker and records what each use case observed. +// Everything is written under dist/e2e, which the repository ignores. +import { execFile as execFileCb } from 'node:child_process' +import { createHash, createHmac, randomBytes } from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as http from 'node:http' +import * as net from 'node:net' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { S3Client } from '@aws-sdk/client-s3' +import { expect, type Browser, type BrowserContext, type Page } from '@playwright/test' + +const execFile = promisify(execFileCb) + +export const REPO_ROOT = path.resolve(process.cwd(), '../..') +export const OUT = 'dist/e2e' +export const ACCESS_KEY = 'e2e-key' +export const SECRET_KEY = 'e2e-secret' +const REGION = 'us-east-1' +const NAMESPACE = (process.env.VERIFY_SCAN_NAMESPACE ?? `local_${randomBytes(4).toString('hex')}`).toLowerCase() +const IMAGE = `devcloud-s3:e2e-${NAMESPACE}` + +export type Actual = Record + +// ---------------------------------------------------------------- ports + +let nextDefaultPort = 19_200 + +/** A port leased by the verification recorder, or a local default outside it. */ +export function port(name: string): number { + const leased = process.env[`VERIFY_SCAN_PORT_${name.toUpperCase()}`] + return leased ? Number(leased) : nextDefaultPort++ +} + +// ---------------------------------------------------------------- docker + +async function docker(...args: string[]): Promise { + const { stdout } = await execFile('docker', args, { maxBuffer: 16 * 1024 * 1024 }) + return stdout.trim() +} + +export async function revision(): Promise { + const { stdout } = await execFile('git', ['rev-parse', '--short=7', 'HEAD'], { cwd: REPO_ROOT }) + return stdout.trim() +} + +export async function buildImage(): Promise { + await docker('build', '-q', '-f', path.join(REPO_ROOT, 'services/s3/Dockerfile'), '-t', IMAGE, REPO_ROOT) +} + +export async function removeImage(): Promise { + await docker('image', 'rm', '-f', IMAGE).catch(() => '') +} + +export async function inspectExposedPorts(): Promise { + return docker('image', 'inspect', IMAGE, '--format', '{{json .Config.ExposedPorts}}') +} + +export class Container { + private constructor(private readonly name: string) {} + + static async run(label: string, env: Record, ports: { s3?: number; console?: number } = {}): Promise { + const name = `${NAMESPACE}-${label}` + const published = [ + ...(ports.s3 ? ['-p', `127.0.0.1:${ports.s3}:9000`] : []), + ...(ports.console ? ['-p', `127.0.0.1:${ports.console}:9001`] : []), + ] + const settings = Object.entries(env).flatMap(([key, value]) => ['-e', `${key}=${value}`]) + await docker('run', '-d', '--name', name, ...published, ...settings, IMAGE) + return new Container(name) + } + + /** The exit code once stopped, or null when still running after `waitMs`. */ + async exitCodeAfter(waitMs: number): Promise { + const deadline = Date.now() + waitMs + while (Date.now() < deadline) { + const [running, code] = (await docker('inspect', this.name, '--format', '{{.State.Running}} {{.State.ExitCode}}')).split(' ') + if (running === 'false') return Number(code) + await sleep(200) + } + return null + } + + async remove(): Promise { + await docker('rm', '-f', this.name).catch(() => '') + } +} + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export async function waitForHttp(targetPort: number): Promise { + const deadline = Date.now() + 20_000 + for (;;) { + const response = await raw(targetPort, 'GET', '/').catch(() => null) + if (response) return + if (Date.now() > deadline) throw new Error(`nothing answered on port ${targetPort}`) + await sleep(200) + } +} + +export const strictEnv = { + DEVCLOUD_S3_AUTH_MODE: 'strict', + DEVCLOUD_S3_ACCESS_KEY_ID: ACCESS_KEY, + DEVCLOUD_S3_SECRET_ACCESS_KEY: SECRET_KEY, +} + +// ---------------------------------------------------------------- clients + +export function sdk(targetPort: number, options: { secret?: string; clockOffsetMs?: number } = {}): S3Client { + const client = new S3Client({ + endpoint: `http://127.0.0.1:${targetPort}`, + forcePathStyle: true, + region: REGION, + maxAttempts: 1, + credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: options.secret ?? SECRET_KEY }, + }) + if (options.clockOffsetMs) client.config.systemClockOffset = options.clockOffsetMs + return client +} + +export interface RawResponse { + status: number + headers: http.IncomingHttpHeaders + body: Buffer +} + +/** Sends the path byte for byte: `fetch` would resolve dot segments in it. */ +export function raw( + targetPort: number, + method: string, + target: string, + headers: Record = {}, + body?: Buffer | string, +): Promise { + return new Promise((resolve, reject) => { + const request = http.request({ host: '127.0.0.1', port: targetPort, method, path: target, headers }, (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.on('end', () => resolve({ status: response.statusCode ?? 0, headers: response.headers, body: Buffer.concat(chunks) })) + }) + request.on('error', reject) + request.setTimeout(5_000, () => request.destroy(new Error('request timed out'))) + request.end(body) + }) +} + +/** Writes bytes to a socket and returns the reply; empty when closed without one. */ +export function rawSocket(targetPort: number, wire: string): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(targetPort, '127.0.0.1', () => socket.write(wire)) + let reply = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => (reply += chunk)) + socket.on('end', () => resolve(reply)) + socket.on('close', () => resolve(reply)) + socket.on('error', (error) => (reply ? resolve(reply) : reject(error))) + socket.setTimeout(5_000, () => socket.destroy()) + }) +} + +const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex') +const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest() + +/** + * Uploads with signed chunks (STREAMING-AWS4-HMAC-SHA256-PAYLOAD), which the + * JavaScript SDK does not send. `tamper` changes one byte after signing. + */ +export async function signedChunkUpload(targetPort: number, target: string, chunks: Buffer[], tamper = false): Promise { + const amzDate = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z') + const date = amzDate.slice(0, 8) + const scope = `${date}/${REGION}/s3/aws4_request` + const key = hmac(hmac(hmac(hmac(`AWS4${SECRET_KEY}`, date), REGION), 's3'), 'aws4_request') + const payloadHash = 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' + const headers: Record = { + 'content-encoding': 'aws-chunked', + host: `127.0.0.1:${targetPort}`, + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': amzDate, + 'x-amz-decoded-content-length': String(chunks.reduce((sum, chunk) => sum + chunk.length, 0)), + } + const names = Object.keys(headers).sort() + const canonical = [ + 'PUT', + target, + '', + names.map((name) => `${name}:${headers[name]}\n`).join(''), + names.join(';'), + payloadHash, + ].join('\n') + const seed = hmac(key, ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n')).toString('hex') + + const framed: Buffer[] = [] + let previous = seed + for (const chunk of [...chunks, Buffer.alloc(0)]) { + previous = hmac(key, ['AWS4-HMAC-SHA256-PAYLOAD', amzDate, scope, previous, sha256(''), sha256(chunk)].join('\n')).toString('hex') + framed.push(Buffer.from(`${chunk.length.toString(16)};chunk-signature=${previous}\r\n`), chunk, Buffer.from('\r\n')) + } + const body = Buffer.concat(framed) + if (tamper) { + const at = body.indexOf('\r\n') + 2 + body[at] ^= 1 + } + return raw(targetPort, 'PUT', target, { + ...headers, + authorization: `AWS4-HMAC-SHA256 Credential=${ACCESS_KEY}/${scope}, SignedHeaders=${names.join(';')}, Signature=${seed}`, + 'content-length': String(body.length), + }, body) +} + +// ---------------------------------------------------------------- browser-visible origin + +export interface WebOrigin { + url: string + close(): Promise +} + +/** A minimal app on its own origin, so requests from its page are cross-origin. */ +export async function serveWebOrigin(webPort: number): Promise { + const server = http.createServer((_, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(`Demo web app + +

Demo web app

    `) + }) + await new Promise((resolve) => server.listen(webPort, '127.0.0.1', resolve)) + return { + url: `http://127.0.0.1:${webPort}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} + +export interface PageFetch { + ok: boolean + status: number | null + etag: string | null + body: string | null +} + +/** `fetch` from inside the page, so the browser enforces CORS. A block is `ok: false`. */ +export function pageFetch(page: Page, url: string, init: RequestInit = {}): Promise { + return page.evaluate(async ({ url, init }) => { + try { + const response = await fetch(url, init) + return { ok: true, status: response.status, etag: response.headers.get('etag'), body: await response.text() } + } catch { + return { ok: false, status: null, etag: null, body: null } + } + }, { url, init }) +} + +// ---------------------------------------------------------------- recording + +interface Step { + system: string + screen: string + title: string + screenshot?: string + action?: { kind: string; label: string } + checkpoint?: string +} + +interface Checkpoint { + id: string + correlation_id: string + actual: Actual + evidence?: string[] + video?: { path: string; start_s: number; end_s: number } + interval?: [number, number] +} + +/** One use case's observations, written even when an assertion fails. */ +export class Recorder { + readonly checkpoints: Checkpoint[] = [] + readonly steps: Step[] = [] + readonly correlationId: string + systems: Record = {} + startedAt = 0 + endedAt = 0 + private clock = Date.now() + private segmentStart = 0 + + constructor( + readonly useCase: string, + public page: Page | null, + clockStart: number, + ) { + this.clock = clockStart + this.correlationId = `${useCase.toLowerCase()}-${randomBytes(4).toString('hex')}` + this.startedAt = this.now() + this.segmentStart = this.startedAt + } + + now(): number { + return (Date.now() - this.clock) / 1000 + } + + /** A short name unique to this attempt, valid in a bucket name. */ + id(name: string): string { + return `${name}-${this.correlationId.slice(-8)}` + } + + beginCheckpoint(): void { + this.segmentStart = this.now() + } + + /** Shows progress in the page, so the recording explains what is happening. */ + async show(line: string): Promise { + if (!this.page || this.page.isClosed()) return + await this.page.evaluate((text) => { + const item = document.createElement('li') + item.textContent = text + document.getElementById('log')?.append(item) + }, line).catch(() => undefined) + } + + /** Records a value read from a browser screen; its interval is cut from the page video. */ + async ui(id: string, actual: Actual, expected: Actual): Promise { + await sleep(1_200) + this.checkpoints.push({ id, correlation_id: this.correlationId, actual, interval: [this.segmentStart, this.now()] }) + expect.soft(actual, id).toEqual(expected) + this.segmentStart = this.now() + } + + /** Records a value read from an API, keeping it as an attachment. */ + async api(id: string, actual: Actual, expected: Actual): Promise { + const file = `${OUT}/evidence/${this.useCase}-${id}.json` + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, JSON.stringify({ correlation_id: this.correlationId, actual }, null, 2)) + this.checkpoints.push({ + id, + correlation_id: this.correlationId, + actual, + evidence: [file], + interval: [this.segmentStart, this.now()], + }) + await this.show(`${id}: ${JSON.stringify(actual)}`) + if (this.page) await sleep(800) + expect.soft(actual, id).toEqual(expected) + this.segmentStart = this.now() + } + + async step(step: Omit, screenshot = false): Promise { + const entry: Step = { ...step } + if (screenshot && this.page) { + const file = `${OUT}/steps/${this.useCase}-${String(this.steps.length + 1).padStart(2, '0')}.png` + await fs.mkdir(path.dirname(file), { recursive: true }) + await this.page.screenshot({ path: file }) + entry.screenshot = file + } + this.steps.push(entry) + } +} + +export async function videoDuration(file: string): Promise { + const { stdout } = await execFile('ffprobe', ['-v', 'error', '-count_packets', '-show_entries', 'format=duration', '-of', 'csv=p=0', file]).catch(() => ({ stdout: '' })) + const seconds = Number(stdout.trim()) + if (Number.isFinite(seconds) && seconds > 0) return seconds + // Browser recordings can omit the container duration: read the last packet's time. + const packets = await execFile('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'packet=pts_time', '-of', 'csv=p=0', file], { maxBuffer: 64 * 1024 * 1024 }) + const times = packets.stdout.trim().split('\n').map(Number).filter(Number.isFinite) + return Math.max(...times) +} + +/** Opens a recorded page; its video timeline starts at the returned clock. */ +export async function openRecordedPage(browser: Browser): Promise<{ context: BrowserContext; page: Page; clockStart: number }> { + const context = await browser.newContext({ + acceptDownloads: true, + viewport: { width: 1280, height: 720 }, + recordVideo: { dir: `${OUT}/raw`, size: { width: 1280, height: 720 } }, + }) + const page = await context.newPage() + return { context, page, clockStart: Date.now() } +} + +/** Closes the page's context and finalizes its recording. */ +export async function saveVideo(context: BrowserContext, page: Page, name: string): Promise<{ file: string; duration: number }> { + await context.close() + const video = page.video() + if (!video) throw new Error('the page has no video') + const file = `${OUT}/video/${name}.webm` + await fs.mkdir(path.dirname(file), { recursive: true }) + await video.saveAs(file) + return { file, duration: await videoDuration(file) } +} + +export async function writeScenario(recorder: Recorder, video?: { file: string; duration: number }): Promise { + const checkpoints = recorder.checkpoints.map(({ interval, ...checkpoint }) => { + if (checkpoint.evidence || !video || !interval) return checkpoint + const end_s = Math.min(interval[1], video.duration) + const start_s = Math.min(interval[0], Math.max(0, end_s - 0.5)) + return { ...checkpoint, video: { path: video.file, start_s, end_s } } + }) + await fs.mkdir(OUT, { recursive: true }) + await fs.writeFile(`${OUT}/scenario.json`, JSON.stringify({ + use_case: recorder.useCase, + run_token: process.env.VERIFY_SCAN_RUN_TOKEN ?? 'local', + correlation_id: recorder.correlationId, + systems: recorder.systems, + checkpoints, + steps: recorder.steps, + }, null, 2)) +} diff --git a/web/s3-console/e2e/image.spec.ts b/web/s3-console/e2e/image.spec.ts new file mode 100644 index 0000000..0769279 --- /dev/null +++ b/web/s3-console/e2e/image.spec.ts @@ -0,0 +1,75 @@ +// One test per use case: `npx playwright test image.spec.ts --grep ""`. +import { test } from '@playwright/test' +import { + Recorder, + buildImage, + openRecordedPage, + removeImage, + saveVideo, + writeScenario, +} from './harness' +import { + browserUsesCors, + consoleManagesObjects, + existingGateStillPasses, + imageServesBothPorts, + sdkSignsOverSocket, +} from './usecases' + +test.beforeAll(async () => { + test.setTimeout(600_000) + await buildImage() +}) + +test.afterAll(removeImage) + +test('browser uses cors', async ({ browser }) => { + const { context, page, clockStart } = await openRecordedPage(browser) + const recorder = new Recorder('UC-001', page, clockStart) + let video + try { + await browserUsesCors(recorder, page) + } finally { + video = await saveVideo(context, page, 'UC-001') + await writeScenario(recorder, video) + } +}) + +test('sdk signs over socket', async () => { + const recorder = new Recorder('UC-002', null, Date.now()) + try { + await sdkSignsOverSocket(recorder) + } finally { + await writeScenario(recorder) + } +}) + +test('console manages objects', async ({ browser }) => { + const { context, page, clockStart } = await openRecordedPage(browser) + const recorder = new Recorder('UC-003', page, clockStart) + let video + try { + await consoleManagesObjects(recorder, page) + } finally { + video = await saveVideo(context, page, 'UC-003') + await writeScenario(recorder, video) + } +}) + +test('image serves both ports', async () => { + const recorder = new Recorder('UC-004', null, Date.now()) + try { + await imageServesBothPorts(recorder) + } finally { + await writeScenario(recorder) + } +}) + +test('existing gate still passes', async () => { + const recorder = new Recorder('UC-005', null, Date.now()) + try { + await existingGateStillPasses(recorder) + } finally { + await writeScenario(recorder) + } +}) diff --git a/web/s3-console/e2e/usecases.ts b/web/s3-console/e2e/usecases.ts new file mode 100644 index 0000000..2050915 --- /dev/null +++ b/web/s3-console/e2e/usecases.ts @@ -0,0 +1,485 @@ +// The business flows, shared by the per-use-case tests and the demo recording. +import { execFile as execFileCb } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { Readable } from 'node:stream' +import { promisify } from 'node:util' +import { + CreateBucketCommand, + DeleteBucketCorsCommand, + GetBucketCorsCommand, + GetObjectCommand, + ListBucketsCommand, + ListObjectsV2Command, + PutBucketCorsCommand, + PutObjectCommand, + type S3Client, +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import { expect, type Page } from '@playwright/test' +import { + ACCESS_KEY, + Container, + OUT, + REPO_ROOT, + SECRET_KEY, + inspectExposedPorts, + pageFetch, + port, + raw, + rawSocket, + revision, + sdk, + serveWebOrigin, + signedChunkUpload, + sleep, + strictEnv, + waitForHttp, + type Recorder, +} from './harness' + +const execFile = promisify(execFileCb) + +const errorName = async (call: Promise): Promise => { + try { + await call + return null + } catch (error) { + return (error as Error).name + } +} + +const readBody = async (client: S3Client, bucket: string, key: string): Promise => { + try { + const object = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key })) + return (await object.Body?.transformToString()) ?? null + } catch { + return null + } +} + +const corsFor = (origin: string) => ({ + CORSRules: [{ AllowedOrigins: [origin], AllowedMethods: ['GET', 'PUT'], AllowedHeaders: ['*'], ExposeHeaders: ['ETag'], MaxAgeSeconds: 60 }], +}) + +const apiStep = (recorder: Recorder, checkpoint: string, title: string, label: string, system = 's3') => + recorder.step({ system, screen: checkpoint.toLowerCase(), title, checkpoint, action: { kind: 'api', label } }) + +// ---------------------------------------------------------------- UC-001 + +export async function browserUsesCors(recorder: Recorder, page: Page): Promise { + const rev = await revision() + recorder.systems = { webapp: rev, s3: rev } + const web = await serveWebOrigin(port('web')) + const s3Plain = port('s3plain') + const s3Default = port('s3default') + const s3Strict = port('s3strict') + const containers = await Promise.all([ + Container.run('plain', {}, { s3: s3Plain }), + Container.run('default', { DEVCLOUD_S3_CORS_ALLOW_ORIGINS: web.url }, { s3: s3Default }), + Container.run('strict', strictEnv, { s3: s3Strict }), + ]) + try { + await Promise.all([s3Plain, s3Default, s3Strict].map(waitForHttp)) + await page.goto(web.url) + const plain = sdk(s3Plain) + const configured = recorder.id('configured') + const unconfigured = recorder.id('unconfigured') + await plain.send(new CreateBucketCommand({ Bucket: unconfigured })) + + // CP-001: the configuration API + const scratch = recorder.id('scratch') + await plain.send(new CreateBucketCommand({ Bucket: scratch })) + const rejected = await raw(s3Plain, 'PUT', `/${scratch}?cors`, {}, '') + await errorName(plain.send(new PutBucketCorsCommand({ Bucket: scratch, CORSConfiguration: corsFor(web.url) }))) + const saved = await plain.send(new GetBucketCorsCommand({ Bucket: scratch })).catch(() => null) + await errorName(plain.send(new DeleteBucketCorsCommand({ Bucket: scratch }))) + const afterDelete = await errorName(plain.send(new GetBucketCorsCommand({ Bucket: scratch }))) + await recorder.step({ system: 'webapp', screen: 'app', title: 'Web app on another origin', action: { kind: 'api', label: 'PutBucketCors' } }, true) + await apiStep(recorder, 'CP-001', 'Bucket CORS configuration', 'GET ?cors') + await recorder.api('CP-001', { + saved: saved?.CORSRules?.[0]?.AllowedOrigins?.[0] === web.url, + invalid_rejected: rejected.status === 400, + deleted: afterDelete === 'NoSuchCORSConfiguration', + }, { saved: true, invalid_rejected: true, deleted: true }) + + await plain.send(new CreateBucketCommand({ Bucket: configured })) + await errorName(plain.send(new PutBucketCorsCommand({ Bucket: configured, CORSConfiguration: corsFor(web.url) }))) + const objectUrl = `http://127.0.0.1:${s3Plain}/${configured}/note.txt` + + // CP-002: preflight and upload from the browser + const preflight = (method: string, origin = web.url) => + raw(s3Plain, 'OPTIONS', `/${configured}/note.txt`, { origin, 'access-control-request-method': method }) + const allowed = await preflight('PUT') + const denied = await preflight('DELETE') + const put = await pageFetch(page, objectUrl, { method: 'PUT', body: 'from the browser', headers: { 'x-amz-meta-source': 'browser' } }) + await recorder.show(`Upload from ${web.url}: ${put.ok ? `HTTP ${put.status}` : 'blocked by the browser'}`) + await recorder.step({ system: 'webapp', screen: 'app', title: 'Upload from the browser', checkpoint: 'CP-002', action: { kind: 'api', label: 'PUT object' } }, true) + await recorder.ui('CP-002', { + preflight_status: allowed.status, + denied_status: denied.status, + browser_put_ok: put.ok && put.status === 200, + }, { preflight_status: 200, denied_status: 403, browser_put_ok: true }) + + // CP-003: reading responses from the browser + const get = await pageFetch(page, objectUrl) + const missing = await pageFetch(page, `http://127.0.0.1:${s3Plain}/${configured}/absent.txt`) + const otherOrigin = await raw(s3Plain, 'GET', `/${configured}/note.txt`, { origin: 'https://other.example' }) + await recorder.show(`Read: ${get.ok ? `"${get.body}" ETag ${get.etag}` : 'blocked by the browser'}`) + await recorder.show(`Missing object: ${missing.ok ? `HTTP ${missing.status}` : 'blocked by the browser'}`) + await recorder.step({ system: 'webapp', screen: 'app', title: 'Read the object and an error', checkpoint: 'CP-003', action: { kind: 'api', label: 'GET object' } }, true) + await recorder.ui('CP-003', { + body_read: get.body === 'from the browser', + etag_visible: Boolean(get.etag), + error_status: missing.status, + other_origin_header: otherOrigin.headers['access-control-allow-origin'] ?? null, + }, { body_read: true, etag_visible: true, error_status: 404, other_origin_header: null }) + + // CP-004: no configuration, the server default, and a bucket's own rules + const blocked = await pageFetch(page, `http://127.0.0.1:${s3Plain}/${unconfigured}`) + const viaDefault = sdk(s3Default) + const defaulted = recorder.id('defaulted') + const ownRule = recorder.id('own-rule') + await viaDefault.send(new CreateBucketCommand({ Bucket: defaulted })) + await viaDefault.send(new CreateBucketCommand({ Bucket: ownRule })) + await errorName(viaDefault.send(new PutBucketCorsCommand({ Bucket: ownRule, CORSConfiguration: corsFor('https://other.example') }))) + const defaultAllowed = await pageFetch(page, `http://127.0.0.1:${s3Default}/${defaulted}`) + const ownRuleResult = await pageFetch(page, `http://127.0.0.1:${s3Default}/${ownRule}`) + await recorder.show(`No configuration: ${blocked.ok ? 'allowed' : 'blocked by the browser'}`) + await recorder.show(`Server default: ${defaultAllowed.ok ? 'allowed' : 'blocked by the browser'}`) + await recorder.show(`Bucket with its own rule: ${ownRuleResult.ok ? 'allowed' : 'blocked by the browser'}`) + await recorder.step({ system: 'webapp', screen: 'app', title: 'Default and bucket rules', checkpoint: 'CP-004', action: { kind: 'api', label: 'strict server' } }, true) + await recorder.ui('CP-004', { + unconfigured_blocked: !blocked.ok, + default_allowed: defaultAllowed.ok, + own_rule_wins: !ownRuleResult.ok, + }, { unconfigured_blocked: true, default_allowed: true, own_rule_wins: true }) + + // CP-005: strict mode answers the preflight but still refuses the unsigned request + const signed = sdk(s3Strict) + const strictBucket = recorder.id('strict') + await signed.send(new CreateBucketCommand({ Bucket: strictBucket })) + await errorName(signed.send(new PutBucketCorsCommand({ Bucket: strictBucket, CORSConfiguration: corsFor(web.url) }))) + const strictPreflight = await raw(s3Strict, 'OPTIONS', `/${strictBucket}/x`, { origin: web.url, 'access-control-request-method': 'PUT' }) + const unsigned = await pageFetch(page, `http://127.0.0.1:${s3Strict}/${strictBucket}/x`, { method: 'PUT', body: 'unsigned' }) + await recorder.show(`Unsigned upload to the strict server: ${unsigned.ok ? `HTTP ${unsigned.status}` : 'blocked by the browser'}`) + await recorder.step({ system: 'webapp', screen: 'app', title: 'Unsigned request to a strict server', checkpoint: 'CP-005' }, true) + await recorder.ui('CP-005', { + preflight_status: strictPreflight.status, + unsigned_status: unsigned.status, + }, { preflight_status: 200, unsigned_status: 403 }) + } finally { + await Promise.all(containers.map((container) => container.remove())) + await web.close() + } +} + +// ---------------------------------------------------------------- UC-002 + +const RESERVED_KEYS = ['a b.txt', 'ๆ—ฅๆœฌ่ชž/ใƒ•ใ‚กใ‚คใƒซ.txt', 'p+q=r&s.txt', "x(1)!'*.txt"] + +export async function sdkSignsOverSocket(recorder: Recorder): Promise { + recorder.systems = { s3: await revision() } + const strictPort = port('s3strict') + const relaxedPort = port('s3plain') + const containers = await Promise.all([ + Container.run('strict', strictEnv, { s3: strictPort }), + Container.run('plain', {}, { s3: relaxedPort }), + ]) + try { + await Promise.all([strictPort, relaxedPort].map(waitForHttp)) + const strict = sdk(strictPort) + const relaxed = sdk(relaxedPort) + const bucket = recorder.id('sdk') + await strict.send(new CreateBucketCommand({ Bucket: bucket })) + await relaxed.send(new CreateBucketCommand({ Bucket: bucket })) + + // CP-001: keys that need percent-encoding + let reservedOk = 0 + for (const key of RESERVED_KEYS) { + const failed = await errorName(strict.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: 'hello' }))) + if (!failed && (await readBody(strict, bucket, key)) === 'hello') reservedOk += 1 + } + await apiStep(recorder, 'CP-001', 'Keys with reserved characters', 'PutObject, GetObject') + await recorder.api('CP-001', { keys: RESERVED_KEYS.length, keys_ok: reservedOk }, { keys: 4, keys_ok: 4 }) + + // CP-002: signatures that must not verify + await strict.send(new PutObjectCommand({ Bucket: bucket, Key: 'plain.txt', Body: 'plain' })) + const wrongSecret = await errorName(sdk(strictPort, { secret: 'not-the-secret' }).send(new ListBucketsCommand({}))) + const presigned = new URL(await getSignedUrl(strict, new GetObjectCommand({ Bucket: bucket, Key: 'plain.txt' }), { expiresIn: 60 })) + const retargeted = await raw(strictPort, 'GET', `/${bucket}/other.txt${presigned.search}`) + await apiStep(recorder, 'CP-002', 'Wrong secret and retargeted URL', 'ListBuckets') + await recorder.api('CP-002', { + wrong_secret: wrongSecret, + retargeted_status: retargeted.status, + retargeted_code: /(\w+)<\/Code>/.exec(retargeted.body.toString())?.[1] ?? null, + }, { wrong_secret: 'SignatureDoesNotMatch', retargeted_status: 403, retargeted_code: 'SignatureDoesNotMatch' }) + + // CP-003: a client clock one hour behind + const skewed = await errorName(sdk(strictPort, { clockOffsetMs: -3_600_000 }).send(new ListBucketsCommand({}))) + await apiStep(recorder, 'CP-003', 'Client clock one hour behind', 'ListBuckets') + await recorder.api('CP-003', { code: skewed }, { code: 'RequestTimeTooSkewed' }) + + // CP-004: signed chunks + const chunks = [Buffer.from('hello '), Buffer.from('world')] + const signedUpload = await signedChunkUpload(strictPort, `/${bucket}/chunks.txt`, chunks) + const signedBody = await readBody(strict, bucket, 'chunks.txt') + const tampered = await signedChunkUpload(strictPort, `/${bucket}/tampered.txt`, chunks, true) + await apiStep(recorder, 'CP-004', 'Signed chunk upload', 'PUT aws-chunked') + await recorder.api('CP-004', { + valid_status: signedUpload.status, + same_body: signedBody === 'hello world', + tampered_code: /(\w+)<\/Code>/.exec(tampered.body.toString())?.[1] ?? null, + }, { valid_status: 200, same_body: true, tampered_code: 'SignatureDoesNotMatch' }) + + // CP-005: the SDK's trailing-checksum stream + const stream = () => Readable.from([Buffer.from('stream '), Buffer.from('body')]) + await errorName(relaxed.send(new PutObjectCommand({ Bucket: bucket, Key: 'stream.txt', Body: stream(), ContentLength: 11 }))) + await errorName(strict.send(new PutObjectCommand({ Bucket: bucket, Key: 'stream.txt', Body: stream(), ContentLength: 11 }))) + await apiStep(recorder, 'CP-005', 'SDK stream upload', 'PutObject stream') + await recorder.api('CP-005', { + relaxed_same: (await readBody(relaxed, bucket, 'stream.txt')) === 'stream body', + strict_same: (await readBody(strict, bucket, 'stream.txt')) === 'stream body', + }, { relaxed_same: true, strict_same: true }) + + // CP-006: presigned URLs + const url = async (expiresIn: number) => new URL(await getSignedUrl(strict, new GetObjectCommand({ Bucket: bucket, Key: 'plain.txt' }), { expiresIn })) + const valid = await url(60) + const shortLived = await url(1) + const longer = new URL(valid) + longer.searchParams.set('X-Amz-Expires', '600') + await sleep(2_500) + await apiStep(recorder, 'CP-006', 'Presigned URLs', 'GET presigned') + await recorder.api('CP-006', { + valid: (await raw(strictPort, 'GET', valid.pathname + valid.search)).status, + expired: (await raw(strictPort, 'GET', shortLived.pathname + shortLived.search)).status, + tampered: (await raw(strictPort, 'GET', longer.pathname + longer.search)).status, + }, { valid: 200, expired: 403, tampered: 403 }) + + // CP-007: HTTP framing + const head = (key: string, headers: string) => `PUT /${bucket}/${key} HTTP/1.1\r\nHost: 127.0.0.1\r\n${headers}\r\n` + await rawSocket(relaxedPort, `${head('te.txt', 'Transfer-Encoding: chunked\r\n')}5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n`) + await relaxed.send(new PutObjectCommand({ Bucket: bucket, Key: 'keep.txt', Body: 'keep me' })) + const conflict = await rawSocket(relaxedPort, `${head('keep.txt', 'Content-Length: 5\r\nContent-Length: 4\r\n')}hello`) + await rawSocket(relaxedPort, `${head('same.txt', 'Content-Length: 5\r\nContent-Length: 5\r\n')}hello`) + await apiStep(recorder, 'CP-007', 'Chunked transfer and repeated lengths', 'PUT over a raw socket') + await recorder.api('CP-007', { + chunked_same: (await readBody(relaxed, bucket, 'te.txt')) === 'hello world', + conflict_refused: conflict === '', + object_kept: (await readBody(relaxed, bucket, 'keep.txt')) === 'keep me', + repeated_ok: (await readBody(relaxed, bucket, 'same.txt')) === 'hello', + }, { chunked_same: true, conflict_refused: true, object_kept: true, repeated_ok: true }) + } finally { + await Promise.all(containers.map((container) => container.remove())) + } +} + +// ---------------------------------------------------------------- UC-003 + +const basicAuth = (user: string, password: string) => `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}` + +const reachable = async (page: Page, url: string): Promise => { + const response = await page.goto(url).catch(() => null) + return Boolean(response?.ok()) && (await page.title()) === 'devcloud S3 console' +} + +async function createBucketInConsole(page: Page, name: string): Promise { + await page.getByLabel('New bucket').fill(name) + await page.getByRole('button', { name: 'Create' }).click() + await expect(page.getByRole('navigation', { name: 'Path' }).getByRole('button', { name })).toBeVisible() +} + +export async function consoleManagesObjects(recorder: Recorder, page: Page): Promise { + const rev = await revision() + recorder.systems = { console: rev, s3: rev } + const consolePort = port('console') + const s3Port = port('s3plain') + const strictConsolePort = port('consolestrict') + const web = await serveWebOrigin(port('web')) + const containers = await Promise.all([ + Container.run('plain', {}, { s3: s3Port, console: consolePort }), + Container.run('strict', strictEnv, { console: strictConsolePort }), + ]) + try { + await waitForHttp(s3Port) + await sleep(500) + const consoleUrl = `http://127.0.0.1:${consolePort}/` + + // CP-001: the whole object workflow + if (!(await reachable(page, consoleUrl))) { + await recorder.step({ system: 'console', screen: 'console', title: 'Console is unreachable', checkpoint: 'CP-001' }, true) + await recorder.ui('CP-001', { reachable: false }, { reachable: true, initial_empty: true, uploaded_via_s3: true, downloaded_same: true, deleted: true }) + return + } + const initialEmpty = await page.getByText('No buckets yet.').isVisible() + await recorder.step({ system: 'console', screen: 'home', title: 'Console with no buckets', action: { kind: 'submit', label: 'Create' } }, true) + const bucket = recorder.id('photos') + await createBucketInConsole(page, bucket) + await recorder.step({ system: 'console', screen: 'bucket', title: 'New empty bucket', action: { kind: 'input', label: 'Upload' } }, true) + + const upload = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'console-')), 'hello world.txt') + await fs.writeFile(upload, 'hello from the console') + await page.locator('input[type=file]').setInputFiles(upload) + await expect(page.getByRole('cell', { name: 'hello world.txt' })).toBeVisible() + await recorder.step({ system: 'console', screen: 'objects', title: 'Uploaded object in the table', action: { kind: 'click', label: 'Download' } }, true) + const listed = await sdk(s3Port).send(new ListObjectsV2Command({ Bucket: bucket })) + + const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('link', { name: 'Download' }).click()]) + const downloaded = await fs.readFile(await download.path(), 'utf8') + + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await page.getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(page.getByText('This location is empty.')).toBeVisible() + await recorder.step({ system: 'console', screen: 'empty', title: 'Object deleted', checkpoint: 'CP-001', action: { kind: 'navigate', label: 'Open the strict console' } }, true) + const deleted = await errorName(sdk(s3Port).send(new GetObjectCommand({ Bucket: bucket, Key: 'hello world.txt' }))) + await recorder.ui('CP-001', { + reachable: true, + initial_empty: initialEmpty, + uploaded_via_s3: listed.Contents?.[0]?.Key === 'hello world.txt', + downloaded_same: downloaded === 'hello from the console' && download.suggestedFilename() === 'hello world.txt', + deleted: deleted === 'NoSuchKey', + }, { reachable: true, initial_empty: true, uploaded_via_s3: true, downloaded_same: true, deleted: true }) + + // CP-002: strict mode asks for the configured credentials + const noCredentials = await raw(strictConsolePort, 'GET', '/api/buckets') + const wrongCredentials = await raw(strictConsolePort, 'GET', '/api/buckets', { authorization: basicAuth(ACCESS_KEY, 'wrong') }) + await page.setExtraHTTPHeaders({ authorization: basicAuth(ACCESS_KEY, SECRET_KEY) }) + await page.goto(`http://127.0.0.1:${strictConsolePort}/`) + const strictBanner = await page.getByText('strict auth').isVisible() + await createBucketInConsole(page, recorder.id('strict')) + await recorder.step({ system: 'console', screen: 'strict', title: 'Strict console after signing in', checkpoint: 'CP-002', action: { kind: 'api', label: 'Cross-site requests' } }, true) + await recorder.ui('CP-002', { + no_credentials: noCredentials.status, + wrong_credentials: wrongCredentials.status, + authorized_create: strictBanner, + }, { no_credentials: 401, wrong_credentials: 401, authorized_create: true }) + await page.setExtraHTTPHeaders({}) + + // CP-003: other sites and other hostnames + await page.goto(web.url) + const crossSiteBucket = recorder.id('cross-site') + const crossSite = await pageFetch(page, `http://127.0.0.1:${consolePort}/api/buckets/${crossSiteBucket}`, { method: 'PUT' }) + const formPost = await raw(consolePort, 'POST', `/api/buckets/${crossSiteBucket}`, { origin: web.url }) + const rebinding = await raw(consolePort, 'GET', '/api/buckets', { host: 'evil.example.com' }) + const buckets = JSON.parse((await raw(consolePort, 'GET', '/api/buckets')).body.toString()) as { buckets: { name: string }[] } + await recorder.show(`Cross-site PUT: ${crossSite.ok ? `HTTP ${crossSite.status}` : 'blocked by the browser'}`) + await recorder.step({ system: 'console', screen: 'other-site', title: 'Request from another site', checkpoint: 'CP-003', action: { kind: 'navigate', label: 'Back to the console' } }, true) + await recorder.api('CP-003', { + cross_site_blocked: !crossSite.ok, + form_post_status: formPost.status, + foreign_host_status: rebinding.status, + bucket_absent: !buckets.buckets.some((item) => item.name === crossSiteBucket), + }, { cross_site_blocked: true, form_post_status: 405, foreign_host_status: 403, bucket_absent: true }) + + // CP-004: a key containing dot segments + const dots = recorder.id('dots') + await sdk(s3Port).send(new CreateBucketCommand({ Bucket: dots })) + await raw(s3Port, 'PUT', `/${dots}/x/../y`, {}, 'dotted') + await raw(s3Port, 'PUT', `/${dots}/y`, {}, 'plain') + await page.goto(`${consoleUrl}?bucket=${dots}&prefix=${encodeURIComponent('x/../')}`) + await expect(page.getByRole('cell', { name: 'y', exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await page.getByRole('button', { name: 'Confirm', exact: true }).click() + await expect(page.getByText('This location is empty.')).toBeVisible() + await recorder.step({ system: 'console', screen: 'dots', title: 'Key with dot segments deleted', checkpoint: 'CP-004', action: { kind: 'click', label: 'Switch bucket' } }, true) + await recorder.ui('CP-004', { + dotted_deleted: (await raw(s3Port, 'GET', `/${dots}/x/../y`)).status === 404, + other_kept: (await raw(s3Port, 'GET', `/${dots}/y`)).body.toString() === 'plain', + }, { dotted_deleted: true, other_kept: true }) + + // CP-005: a late listing from the previous bucket + const first = recorder.id('aaa') + const second = recorder.id('bbb') + for (const name of [first, second]) { + await sdk(s3Port).send(new CreateBucketCommand({ Bucket: name })) + await sdk(s3Port).send(new PutObjectCommand({ Bucket: name, Key: `in-${name}.txt`, Body: name })) + } + let release = () => {} + const held = new Promise((resolve) => (release = resolve)) + await page.route(`**/api/buckets/${first}/objects*`, async (route) => { + await held + await route.continue() + }) + await page.goto(consoleUrl) + await page.getByRole('button', { name: new RegExp(`^${first}`) }).click() + await page.getByRole('button', { name: new RegExp(`^${second}`) }).click() + await expect(page.getByRole('cell', { name: `in-${second}.txt` })).toBeVisible() + release() + await sleep(800) + await recorder.step({ system: 'console', screen: 'switched', title: 'Second bucket after the late response', checkpoint: 'CP-005' }, true) + await recorder.ui('CP-005', { + stale_shown: await page.getByRole('cell', { name: `in-${first}.txt` }).isVisible(), + current_shown: await page.getByRole('cell', { name: `in-${second}.txt` }).isVisible(), + }, { stale_shown: false, current_shown: true }) + await page.unrouteAll({ behavior: 'ignoreErrors' }) + } finally { + await Promise.all(containers.map((container) => container.remove())) + await web.close() + } +} + +// ---------------------------------------------------------------- UC-004 + +export async function imageServesBothPorts(recorder: Recorder): Promise { + recorder.systems = { image: await revision() } + + // CP-001: invalid auth settings stop the container + const typo = await Container.run('typo', { DEVCLOUD_S3_AUTH_MODE: 'strcit' }) + const noCredentials = await Container.run('nocreds', { DEVCLOUD_S3_AUTH_MODE: 'strict' }) + try { + await apiStep(recorder, 'CP-001', 'Invalid auth settings', 'docker run', 'image') + await recorder.api('CP-001', { + typo_exit: await typo.exitCodeAfter(3_000), + no_credentials_exit: await noCredentials.exitCodeAfter(3_000), + }, { typo_exit: 2, no_credentials_exit: 2 }) + } finally { + await typo.remove() + await noCredentials.remove() + } + + // CP-002: default settings serve both ports + const s3Port = port('s3plain') + const consolePort = port('console') + const container = await Container.run('plain', {}, { s3: s3Port, console: consolePort }) + try { + await waitForHttp(s3Port) + await sleep(500) + const listing = await raw(s3Port, 'GET', '/') + const home = await raw(consolePort, 'GET', '/').catch(() => null) + await apiStep(recorder, 'CP-002', 'Default container', 'GET / on 9000 and 9001', 'image') + await recorder.api('CP-002', { + s3_status: listing.status, + console_served: Boolean(home?.body.toString().includes('devcloud S3 console')), + exposes_console: (await inspectExposedPorts()).includes('9001/tcp'), + }, { s3_status: 200, console_served: true, exposes_console: true }) + } finally { + await container.remove() + } +} + +// ---------------------------------------------------------------- UC-005 + +export async function existingGateStillPasses(recorder: Recorder): Promise { + recorder.systems = { devcloud: await revision() } + await recorder.show('Running the existing S3 acceptance gate through devcloud up ...') + const result = await execFile('bash', ['scripts/s3-autoloop/verify.sh'], { + cwd: REPO_ROOT, + maxBuffer: 64 * 1024 * 1024, + env: { + ...process.env, + VERIFY_STAGE: 'full', + S3_VERIFY_PORT: String(port('verifys3')), + DASHBOARD_VERIFY_PORT: String(port('verifydashboard')), + SMTP_VERIFY_PORT: String(port('verifysmtp')), + }, + }).then(({ stdout }) => ({ code: 0, stdout }), (error: { code?: number; stdout?: string }) => ({ code: error.code ?? 1, stdout: error.stdout ?? '' })) + const summary = /Verification: (\d+)\/(\d+) passed, (\d+) failed/.exec(result.stdout) + await fs.mkdir(OUT, { recursive: true }) + await recorder.step({ system: 'devcloud', screen: 'gate', title: 'Existing S3 acceptance gate', checkpoint: 'CP-001' }) + await recorder.api('CP-001', { + exit_code: result.code, + failed: summary ? Number(summary[3]) : null, + complete: Boolean(summary && summary[1] === summary[2]), + }, { exit_code: 0, failed: 0, complete: true }) +} diff --git a/web/s3-console/package-lock.json b/web/s3-console/package-lock.json index 21669c3..38baf7b 100644 --- a/web/s3-console/package-lock.json +++ b/web/s3-console/package-lock.json @@ -12,6 +12,9 @@ "react-dom": "18.3.1" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1163.0", + "@aws-sdk/s3-request-presigner": "^3.1163.0", + "@playwright/test": "^1.65.0", "@types/node": "22.10.7", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", @@ -20,6 +23,350 @@ "vite": "6.4.2" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1002.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1002.2.tgz", + "integrity": "sha512-u57THWR5YOCqjIzg5og8r5dDc54n7hENs2eVy+aw1+Fd+QERa2x72qNlsiMNJI9svSpQp3WevkSKljzIG6x1YA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1163.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1163.0.tgz", + "integrity": "sha512-exn6SxrqC0NMqJ1YmzbhTDj4hJ4GWnzt2Oq0Rafy2BGSWncFnrquK+RX7mG9DzWxTEyYg/gzbrEk93yvm+ibQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1002.2", + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/credential-provider-node": "^3.972.93", + "@aws-sdk/middleware-sdk-s3": "^3.972.86", + "@aws-sdk/signature-v4-multi-region": "^3.996.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.14.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.980.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.980.0.tgz", + "integrity": "sha512-2RVSP/SsPS8HSPPzIPWNYFnSmlOdxx8VDlt5gDOuMaq9UiIi35pUyo6ZEuJcysjVCu0L8Lv5dJevLLpEkyF0/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@aws-sdk/xml-builder": "^3.972.42", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.38.0", + "@smithy/signature-v4": "^5.7.5", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.81.tgz", + "integrity": "sha512-VD+EVZ9UHNZhSm4KtakXZAPzgfOw4mRx66dAXNUtHcdJUisbsE7kpi1PRZFMquvuqVfx52fCDBgJgYyOFT/4hQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.83.tgz", + "integrity": "sha512-NsLT02HXcWAQAgpuDJznoJDPIDH/Hh2LiQVlYc+1bVGsksEUZuYCMKa1E1L5qvzc5LLTphp7BP9LYVIhf+MbQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.14.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.26.tgz", + "integrity": "sha512-vRydnN8wPqh5APe5R+/y4/UOts7QQQQP1UTczJq3fLcLzkQblbkYpLn+Srr8w9S/gzXU1pbiPKTv479unRawAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/credential-provider-env": "^3.972.81", + "@aws-sdk/credential-provider-http": "^3.972.83", + "@aws-sdk/credential-provider-login": "^3.972.88", + "@aws-sdk/credential-provider-process": "^3.972.81", + "@aws-sdk/credential-provider-sso": "^3.973.25", + "@aws-sdk/credential-provider-web-identity": "^3.972.87", + "@aws-sdk/nested-clients": "^3.998.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.88", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.88.tgz", + "integrity": "sha512-J8IcBpuFj3j3RImHq/dVO5HtnUbFgvzg7JQvzLt5A42VmzLLgPLM+CUWN68jpIMdIPayu1bb1T+mJjDv9Sq1aA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/nested-clients": "^3.998.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.93", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.93.tgz", + "integrity": "sha512-f5Grg1sgckk11ojxZ7NUYG1ZCOvcNhuuPngKWVxOKGuZokj8eOmo/75+GyR6qAQx4wAGytglZ53r4a3UPleAPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.81", + "@aws-sdk/credential-provider-http": "^3.972.83", + "@aws-sdk/credential-provider-ini": "^3.973.26", + "@aws-sdk/credential-provider-process": "^3.972.81", + "@aws-sdk/credential-provider-sso": "^3.973.25", + "@aws-sdk/credential-provider-web-identity": "^3.972.87", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.81.tgz", + "integrity": "sha512-6NBoFaNJbHB0YjcKW4WfrYIDmaMjtwbxlRreAf3MnXkD9WJX8XYu62pa78M0AQGgEt2TJmcNxqL3btAAjV67bA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.25.tgz", + "integrity": "sha512-p1fNaa3as6S2L0wrWqe1cEz1r4iGQa7GbW23KJE3rJy+ZZxhPr6pr9cyCAJ7fAnzoMELvQGZZAuv/rG1ogRuOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/nested-clients": "^3.998.0", + "@aws-sdk/token-providers": "3.1162.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.87", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.87.tgz", + "integrity": "sha512-O2P6CUfMP2qi8vgYB6UHYj/NYTEAAdTr2geEVdVchr3JBofK4Q6W917FiVr2sleA72R59YAXMEhrpzPwkX/xdA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/nested-clients": "^3.998.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.86", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.86.tgz", + "integrity": "sha512-S3RunQUwvUJcuJ5VrVgvhN5OsVpK4GHRcuXaSawCbutO4piyHTmpObUKm19HdqqLBqjf6kHSeAOk/7OyAJ+hmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.998.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.998.0.tgz", + "integrity": "sha512-aOD1oQVWG6UjOOEdp0MsrWE93vG8pPbvtymmeNtglGxAARplh1wzhHs4npqtY5JN5GBSrTaLbn1hQQ3W2Lk8ow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.14.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1163.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1163.0.tgz", + "integrity": "sha512-TMIXhU/Bm//qoKUDohjEyxdId83nYVo2UmDGT/kpquDAbJquEvvd9LhEYKARDjP6pYcRuz93jKGm51dci3zrnw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.51.tgz", + "integrity": "sha512-+aHvUugVBuMPXxgkPfWnTMyGdE/yWS8GHi5+qQ37HowS0YqzrbuaDLznj+Dtmtu5jgCdqz36gXmujOToUhNNWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/signature-v4": "^5.7.5", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1162.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1162.0.tgz", + "integrity": "sha512-vOD6Ac0M43LtXaPu+/+wUMQCd27zIpKt/Le/a645qhx7T1MZeEK71PDhtIHGHgnQjGyNSfC6Z5RNud/hQb3veg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.980.0", + "@aws-sdk/nested-clients": "^3.998.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz", + "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.42.tgz", + "integrity": "sha512-smX8/gNP8Kjq+CWTBMZQLQ912vOQjiWExzwzvMO+eujPB5RY8IbtF0AGdUUXHKN7KoDWK9dIcuW0WG269kiP6w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -794,6 +1141,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1165,6 +1528,93 @@ "win32" ] }, + "node_modules/@smithy/core": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.39.0.tgz", + "integrity": "sha512-JJ/46AWQ4ipS1N2eZmUj7yzIhH8NhQIfAE+dce7NSB6dmFh+7hvD4pbofcoT4I0Xzfy/LNhZx3V2QhwYyAiqdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.1.tgz", + "integrity": "sha512-h8BdMUrUBhMhMaeK7QqAdINRZezCGuYOCpcOZZ1ewxbHST9rdTd09xwNpvLTYfzDRehxQiReK4SKbdYZxBA+ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.39.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.14.2.tgz", + "integrity": "sha512-i1uORV1v3qBqP43ayRKzIdm1q5x14tf8N4Dd06/QUX1Szm6VxHMrTsu20X5E63UFsaNZCRjI4SXG0jDmqYa+3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.39.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.5.tgz", + "integrity": "sha512-WPjdr5oWOk1LArpI4pavu+fSo+/evi0Re1olxGvqwuZopd4gYR1UGCi+Soyn/IjmafqbWs+ZC+3AKxJS3ZpwUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.20.0.tgz", + "integrity": "sha512-lCA6QG22dPk9ecHbv91jDQZsmtPUjdS1aE2Il6NNPqe7DrYc0dZQi6DhcESk2KYXH2sp5xbAcGdYMZDFnuzA2A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1289,6 +1739,13 @@ "node": ">=6.0.0" } }, + "node_modules/bowser": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.15.0.tgz", + "integrity": "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.29.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", @@ -1588,6 +2045,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.32", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", @@ -1744,6 +2230,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "dev": true, + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/web/s3-console/package.json b/web/s3-console/package.json index 6927caa..ca6e19b 100644 --- a/web/s3-console/package.json +++ b/web/s3-console/package.json @@ -6,18 +6,22 @@ "scripts": { "build": "tsc -b && vite build", "dev": "vite", - "typecheck": "tsc -b" + "typecheck": "tsc -b", + "test:e2e": "playwright test" }, "dependencies": { "react": "18.3.1", "react-dom": "18.3.1" }, "devDependencies": { - "@vitejs/plugin-react": "4.7.0", - "typescript": "5.9.3", - "vite": "6.4.2", + "@aws-sdk/client-s3": "^3.1163.0", + "@aws-sdk/s3-request-presigner": "^3.1163.0", + "@playwright/test": "^1.65.0", "@types/node": "22.10.7", "@types/react": "18.3.18", - "@types/react-dom": "18.3.5" + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.7.0", + "typescript": "5.9.3", + "vite": "6.4.2" } } diff --git a/web/s3-console/playwright.config.ts b/web/s3-console/playwright.config.ts new file mode 100644 index 0000000..de37ffa --- /dev/null +++ b/web/s3-console/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@playwright/test' + +// Black-box suite against the real Docker image; see e2e/harness.ts. +export default defineConfig({ + testDir: 'e2e', + outputDir: 'dist/e2e/pw', + workers: 1, + retries: 0, + timeout: 600_000, +}) From 09b4e59c529841c23d87e9a71a42c949a7a3e287 Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 6 Nov 2026 13:10:03 +0900 Subject: [PATCH 3/6] fix(s3): keep the console serving through transient accept errors Shares the accept retry policy main added to the S3 listener. --- services/s3/src/console.rs | 13 ++++++++++--- services/s3/src/http.rs | 23 +++++++++++++---------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/services/s3/src/console.rs b/services/s3/src/console.rs index bc5762c..8099f95 100644 --- a/services/s3/src/console.rs +++ b/services/s3/src/console.rs @@ -13,8 +13,8 @@ use include_dir::{include_dir, Dir}; use tokio::net::{TcpListener, TcpStream}; use crate::http::{ - constant_time_eq, percent_decode, read_request, route, tag_text_in, write_response, AuthConfig, - Request, Response, + constant_time_eq, is_transient_accept_error, percent_decode, read_request, route, tag_text_in, + write_response, AuthConfig, Request, Response, }; use crate::percent::aws_percent_encode; use crate::store::FileBucketStore; @@ -47,7 +47,14 @@ pub async fn serve( tokio::select! { _ = &mut shutdown => return Ok(()), accepted = listener.accept() => { - let (stream, _) = accepted?; + let (stream, _) = match accepted { + Ok(accepted) => accepted, + Err(e) if is_transient_accept_error(&e) => { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + Err(e) => return Err(e), + }; let store = Arc::clone(&store); let config = Arc::clone(&config); tokio::spawn(async move { diff --git a/services/s3/src/http.rs b/services/s3/src/http.rs index f9423c4..c8633c9 100644 --- a/services/s3/src/http.rs +++ b/services/s3/src/http.rs @@ -182,16 +182,7 @@ pub async fn serve_with_cors( // back off and keep serving instead of stopping (which // would take every devcloud service down with it). Err(e) => { - // Only transient errors; a broken listener still stops. - let transient = matches!( - e.kind(), - std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::Interrupted - | std::io::ErrorKind::WouldBlock - | std::io::ErrorKind::TimedOut - ) || matches!(e.raw_os_error(), Some(12 | 23 | 24)); // ENOMEM, ENFILE, EMFILE - if !transient { + if !is_transient_accept_error(&e) { return Err(e); } tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -209,6 +200,18 @@ pub async fn serve_with_cors( } } +/// Whether an accept failure should be retried. A broken listener still stops. +pub(crate) fn is_transient_accept_error(error: &std::io::Error) -> bool { + matches!( + error.kind(), + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::TimedOut + ) || matches!(error.raw_os_error(), Some(12 | 23 | 24)) // ENOMEM, ENFILE, EMFILE +} + async fn handle_conn( mut stream: TcpStream, store: Arc>, From d5837eef601affd758808f41c6b0c5f7e9c5813b Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 6 Nov 2026 13:10:03 +0900 Subject: [PATCH 4/6] fix(s3): update console vite to 6.4.5 for GHSA-8g5g-mxqh-grgj --- web/s3-console/package-lock.json | 8 ++++---- web/s3-console/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/s3-console/package-lock.json b/web/s3-console/package-lock.json index 38baf7b..42a6c7e 100644 --- a/web/s3-console/package-lock.json +++ b/web/s3-console/package-lock.json @@ -20,7 +20,7 @@ "@types/react-dom": "18.3.5", "@vitejs/plugin-react": "4.7.0", "typescript": "5.9.3", - "vite": "6.4.2" + "vite": "6.4.5" } }, "node_modules/@aws-sdk/checksums": { @@ -2290,9 +2290,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/s3-console/package.json b/web/s3-console/package.json index ca6e19b..53743c7 100644 --- a/web/s3-console/package.json +++ b/web/s3-console/package.json @@ -22,6 +22,6 @@ "@types/react-dom": "18.3.5", "@vitejs/plugin-react": "4.7.0", "typescript": "5.9.3", - "vite": "6.4.2" + "vite": "6.4.5" } } From b8c1c0fdd5d942894108eea02072bbe71e9aa60c Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 6 Nov 2026 13:10:03 +0900 Subject: [PATCH 5/6] docs(s3): list DEVCLOUD_S3_BUCKETS in the image settings --- services/s3/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/services/s3/README.md b/services/s3/README.md index ddaaaac..5364e82 100644 --- a/services/s3/README.md +++ b/services/s3/README.md @@ -22,6 +22,7 @@ that uid first. Requests are path-style only and capped at 128 MiB. | `DEVCLOUD_S3_ACCESS_KEY_ID` | empty | Access key, required in strict mode | | `DEVCLOUD_S3_SECRET_ACCESS_KEY` | empty | Secret key, required in strict mode | | `DEVCLOUD_S3_REGION` | `us-east-1` | Region accepted in the SigV4 credential scope | +| `DEVCLOUD_S3_BUCKETS` | empty | Comma-separated buckets to create at startup; existing buckets are kept | | `DEVCLOUD_S3_CORS_ALLOW_ORIGINS` | empty | Comma-separated origins (`*` and one-wildcard patterns allowed) for buckets without their own CORS configuration | | `DEVCLOUD_S3_CONSOLE_ADDR` | `0.0.0.0:9001` | Console listen address; empty disables the console | | `DEVCLOUD_S3_CONSOLE_ALLOWED_HOSTS` | empty | Extra comma-separated console Host names; `*` disables the check | From 680f065eb6e731d45c9317814a3e6327f23a4767 Mon Sep 17 00:00:00 2001 From: simota Date: Fri, 6 Nov 2026 13:10:04 +0900 Subject: [PATCH 6/6] fix(s3): keep empty segments in console breadcrumb links a//b/ and a/b/ are different locations, so a breadcrumb rebuilt from non-empty segments could lead an upload to the wrong prefix. --- .../assets/console/assets/index-CwBD1Nc8.js | 40 ------------------- .../assets/console/assets/index-DC6_bWwi.js | 40 +++++++++++++++++++ services/s3/assets/console/index.html | 2 +- web/s3-console/e2e/usecases.ts | 17 ++++++-- web/s3-console/src/App.tsx | 10 ++++- 5 files changed, 62 insertions(+), 47 deletions(-) delete mode 100644 services/s3/assets/console/assets/index-CwBD1Nc8.js create mode 100644 services/s3/assets/console/assets/index-DC6_bWwi.js diff --git a/services/s3/assets/console/assets/index-CwBD1Nc8.js b/services/s3/assets/console/assets/index-CwBD1Nc8.js deleted file mode 100644 index b549e4d..0000000 --- a/services/s3/assets/console/assets/index-CwBD1Nc8.js +++ /dev/null @@ -1,40 +0,0 @@ -(function(){const R=document.createElement("link").relList;if(R&&R.supports&&R.supports("modulepreload"))return;for(const D of document.querySelectorAll('link[rel="modulepreload"]'))J(D);new MutationObserver(D=>{for(const $ of D)if($.type==="childList")for(const se of $.addedNodes)se.tagName==="LINK"&&se.rel==="modulepreload"&&J(se)}).observe(document,{childList:!0,subtree:!0});function h(D){const $={};return D.integrity&&($.integrity=D.integrity),D.referrerPolicy&&($.referrerPolicy=D.referrerPolicy),D.crossOrigin==="use-credentials"?$.credentials="include":D.crossOrigin==="anonymous"?$.credentials="omit":$.credentials="same-origin",$}function J(D){if(D.ep)return;D.ep=!0;const $=h(D);fetch(D.href,$)}})();var Ni={exports:{}},wr={},Pi={exports:{}},I={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var za;function Bf(){if(za)return I;za=1;var S=Symbol.for("react.element"),R=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),J=Symbol.for("react.strict_mode"),D=Symbol.for("react.profiler"),$=Symbol.for("react.provider"),se=Symbol.for("react.context"),fe=Symbol.for("react.forward_ref"),W=Symbol.for("react.suspense"),xe=Symbol.for("react.memo"),ye=Symbol.for("react.lazy"),Q=Symbol.iterator;function G(c){return c===null||typeof c!="object"?null:(c=Q&&c[Q]||c["@@iterator"],typeof c=="function"?c:null)}var he={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Oe=Object.assign,Z={};function U(c,y,M){this.props=c,this.context=y,this.refs=Z,this.updater=M||he}U.prototype.isReactComponent={},U.prototype.setState=function(c,y){if(typeof c!="object"&&typeof c!="function"&&c!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,c,y,"setState")},U.prototype.forceUpdate=function(c){this.updater.enqueueForceUpdate(this,c,"forceUpdate")};function et(){}et.prototype=U.prototype;function Ke(c,y,M){this.props=c,this.context=y,this.refs=Z,this.updater=M||he}var K=Ke.prototype=new et;K.constructor=Ke,Oe(K,U.prototype),K.isPureReactComponent=!0;var ee=Array.isArray,De=Object.prototype.hasOwnProperty,ge={current:null},de={key:!0,ref:!0,__self:!0,__source:!0};function we(c,y,M){var F,A={},V=null,q=null;if(y!=null)for(F in y.ref!==void 0&&(q=y.ref),y.key!==void 0&&(V=""+y.key),y)De.call(y,F)&&!de.hasOwnProperty(F)&&(A[F]=y[F]);var Y=arguments.length-2;if(Y===1)A.children=M;else if(1>>1,y=m[c];if(0>>1;cD(A,x))VD(q,A)?(m[c]=q,m[V]=x,c=V):(m[c]=A,m[F]=x,c=F);else if(VD(q,x))m[c]=q,m[V]=x,c=V;else break e}}return E}function D(m,E){var x=m.sortIndex-E.sortIndex;return x!==0?x:m.id-E.id}if(typeof performance=="object"&&typeof performance.now=="function"){var $=performance;S.unstable_now=function(){return $.now()}}else{var se=Date,fe=se.now();S.unstable_now=function(){return se.now()-fe}}var W=[],xe=[],ye=1,Q=null,G=3,he=!1,Oe=!1,Z=!1,U=typeof setTimeout=="function"?setTimeout:null,et=typeof clearTimeout=="function"?clearTimeout:null,Ke=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function K(m){for(var E=h(xe);E!==null;){if(E.callback===null)J(xe);else if(E.startTime<=m)J(xe),E.sortIndex=E.expirationTime,R(W,E);else break;E=h(xe)}}function ee(m){if(Z=!1,K(m),!Oe)if(h(W)!==null)Oe=!0,ze(De);else{var E=h(xe);E!==null&&re(ee,E.startTime-m)}}function De(m,E){Oe=!1,Z&&(Z=!1,et(we),we=-1),he=!0;var x=G;try{for(K(E),Q=h(W);Q!==null&&(!(Q.expirationTime>E)||m&&!kt());){var c=Q.callback;if(typeof c=="function"){Q.callback=null,G=Q.priorityLevel;var y=c(Q.expirationTime<=E);E=S.unstable_now(),typeof y=="function"?Q.callback=y:Q===h(W)&&J(W),K(E)}else J(W);Q=h(W)}if(Q!==null)var M=!0;else{var F=h(xe);F!==null&&re(ee,F.startTime-E),M=!1}return M}finally{Q=null,G=x,he=!1}}var ge=!1,de=null,we=-1,Ye=5,Be=-1;function kt(){return!(S.unstable_now()-Bem||125c?(m.sortIndex=x,R(xe,m),h(W)===null&&m===h(xe)&&(Z?(et(we),we=-1):Z=!0,re(ee,x-c))):(m.sortIndex=y,R(W,m),Oe||he||(Oe=!0,ze(De))),m},S.unstable_shouldYield=kt,S.unstable_wrapCallback=function(m){var E=G;return function(){var x=G;G=E;try{return m.apply(this,arguments)}finally{G=x}}}})(Ti)),Ti}var Oa;function Hf(){return Oa||(Oa=1,Li.exports=Vf()),Li.exports}/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Da;function Wf(){if(Da)return Qe;Da=1;var S=ji(),R=Hf();function h(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),W=Object.prototype.hasOwnProperty,xe=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ye={},Q={};function G(e){return W.call(Q,e)?!0:W.call(ye,e)?!1:xe.test(e)?Q[e]=!0:(ye[e]=!0,!1)}function he(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Oe(e,t,n,r){if(t===null||typeof t>"u"||he(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Z(e,t,n,r,l,u,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=i}var U={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){U[e]=new Z(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];U[t]=new Z(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){U[e]=new Z(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){U[e]=new Z(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){U[e]=new Z(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){U[e]=new Z(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){U[e]=new Z(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){U[e]=new Z(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){U[e]=new Z(e,5,!1,e.toLowerCase(),null,!1,!1)});var et=/[\-:]([a-z])/g;function Ke(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(et,Ke);U[t]=new Z(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(et,Ke);U[t]=new Z(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(et,Ke);U[t]=new Z(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){U[e]=new Z(e,1,!1,e.toLowerCase(),null,!1,!1)}),U.xlinkHref=new Z("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){U[e]=new Z(e,1,!1,e.toLowerCase(),null,!0,!0)});function K(e,t,n,r){var l=U.hasOwnProperty(t)?U[t]:null;(l!==null?l.type!==0:r||!(2o||l[i]!==u[o]){var s=` -`+l[i].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=i&&0<=o);break}}}finally{M=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?y(e):""}function A(e){switch(e.tag){case 5:return y(e.type);case 16:return y("Lazy");case 13:return y("Suspense");case 19:return y("SuspenseList");case 0:case 2:case 15:return e=F(e.type,!1),e;case 11:return e=F(e.type.render,!1),e;case 1:return e=F(e.type,!0),e;default:return""}}function V(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case de:return"Fragment";case ge:return"Portal";case Ye:return"Profiler";case we:return"StrictMode";case Me:return"Suspense";case Xe:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case kt:return(e.displayName||"Context")+".Consumer";case Be:return(e._context.displayName||"Context")+".Provider";case tt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ie:return t=e.displayName||null,t!==null?t:V(e.type)||"Memo";case ze:t=e._payload,e=e._init;try{return V(e(t))}catch{}}return null}function q(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return V(t);case 8:return t===we?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Y(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ge(e){var t=le(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,u.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function kr(e){e._valueTracker||(e._valueTracker=Ge(e))}function Oi(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=le(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Sr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Ol(e,t){var n=t.checked;return x({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Di(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Y(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Mi(e,t){t=t.checked,t!=null&&K(e,"checked",t,!1)}function Dl(e,t){Mi(e,t);var n=Y(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ml(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ml(e,t.type,Y(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ii(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ml(e,t,n){(t!=="number"||Sr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Dn=Array.isArray;function an(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=xr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Mn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var In={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Va=["Webkit","ms","Moz","O"];Object.keys(In).forEach(function(e){Va.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),In[t]=In[e]})});function Vi(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||In.hasOwnProperty(e)&&In[e]?(""+t).trim():t+"px"}function Hi(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Vi(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Ha=x({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Ul(e,t){if(t){if(Ha[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(h(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(h(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(h(61))}if(t.style!=null&&typeof t.style!="object")throw Error(h(62))}}function Bl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var $l=null;function Al(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Vl=null,cn=null,fn=null;function Wi(e){if(e=lr(e)){if(typeof Vl!="function")throw Error(h(280));var t=e.stateNode;t&&(t=Qr(t),Vl(e.stateNode,e.type,t))}}function Qi(e){cn?fn?fn.push(e):fn=[e]:cn=e}function Ki(){if(cn){var e=cn,t=fn;if(fn=cn=null,Wi(e),t)for(e=0;e>>=0,e===0?32:31-(ec(e)/tc|0)|0}var Pr=64,zr=4194304;function $n(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Lr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,i=n&268435455;if(i!==0){var o=i&~l;o!==0?r=$n(o):(u&=i,u!==0&&(r=$n(u)))}else i=n&~l,i!==0?r=$n(i):u!==0&&(r=$n(u));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function An(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-st(t),e[t]=n}function uc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Gn),So=" ",xo=!1;function Eo(e,t){switch(e){case"keyup":return Oc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Co(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var mn=!1;function Mc(e,t){switch(e){case"compositionend":return Co(t);case"keypress":return t.which!==32?null:(xo=!0,So);case"textInput":return e=t.data,e===So&&xo?null:e;default:return null}}function Ic(e,t){if(mn)return e==="compositionend"||!iu&&Eo(e,t)?(e=ho(),Dr=eu=Ot=null,mn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=jo(n)}}function Oo(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Oo(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Do(){for(var e=window,t=Sr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Sr(e.document)}return t}function au(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Qc(e){var t=Do(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Oo(n.ownerDocument.documentElement,n)){if(r!==null&&au(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=Ro(n,u);var i=Ro(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,hn=null,cu=null,bn=null,fu=!1;function Mo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;fu||hn==null||hn!==Sr(r)||(r=hn,"selectionStart"in r&&au(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),bn&&qn(bn,r)||(bn=r,r=Vr(cu,"onSelect"),0kn||(e.current=Eu[kn],Eu[kn]=null,kn--)}function b(e,t){kn++,Eu[kn]=e.current,e.current=t}var Ft={},Le=It(Ft),$e=It(!1),Zt=Ft;function Sn(e,t){var n=e.type.contextTypes;if(!n)return Ft;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ae(e){return e=e.childContextTypes,e!=null}function Kr(){ne($e),ne(Le)}function Zo(e,t,n){if(Le.current!==Ft)throw Error(h(168));b(Le,t),b($e,n)}function Jo(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(h(108,q(e)||"Unknown",l));return x({},n,r)}function Yr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ft,Zt=Le.current,b(Le,e),b($e,$e.current),!0}function qo(e,t,n){var r=e.stateNode;if(!r)throw Error(h(169));n?(e=Jo(e,t,Zt),r.__reactInternalMemoizedMergedChildContext=e,ne($e),ne(Le),b(Le,e)):ne($e),b($e,n)}var xt=null,Xr=!1,Cu=!1;function bo(e){xt===null?xt=[e]:xt.push(e)}function rf(e){Xr=!0,bo(e)}function Ut(){if(!Cu&&xt!==null){Cu=!0;var e=0,t=X;try{var n=xt;for(X=1;e>=i,l-=i,Et=1<<32-st(t)+l|n<O?(_e=j,j=null):_e=j.sibling;var H=v(f,j,d[O],k);if(H===null){j===null&&(j=_e);break}e&&j&&H.alternate===null&&t(f,j),a=u(H,a,O),T===null?L=H:T.sibling=H,T=H,j=_e}if(O===d.length)return n(f,j),ue&&qt(f,O),L;if(j===null){for(;OO?(_e=j,j=null):_e=j.sibling;var Yt=v(f,j,H.value,k);if(Yt===null){j===null&&(j=_e);break}e&&j&&Yt.alternate===null&&t(f,j),a=u(Yt,a,O),T===null?L=Yt:T.sibling=Yt,T=Yt,j=_e}if(H.done)return n(f,j),ue&&qt(f,O),L;if(j===null){for(;!H.done;O++,H=d.next())H=w(f,H.value,k),H!==null&&(a=u(H,a,O),T===null?L=H:T.sibling=H,T=H);return ue&&qt(f,O),L}for(j=r(f,j);!H.done;O++,H=d.next())H=C(j,f,O,H.value,k),H!==null&&(e&&H.alternate!==null&&j.delete(H.key===null?O:H.key),a=u(H,a,O),T===null?L=H:T.sibling=H,T=H);return e&&j.forEach(function(Uf){return t(f,Uf)}),ue&&qt(f,O),L}function me(f,a,d,k){if(typeof d=="object"&&d!==null&&d.type===de&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case De:e:{for(var L=d.key,T=a;T!==null;){if(T.key===L){if(L=d.type,L===de){if(T.tag===7){n(f,T.sibling),a=l(T,d.props.children),a.return=f,f=a;break e}}else if(T.elementType===L||typeof L=="object"&&L!==null&&L.$$typeof===ze&&us(L)===T.type){n(f,T.sibling),a=l(T,d.props),a.ref=ur(f,T,d),a.return=f,f=a;break e}n(f,T);break}else t(f,T);T=T.sibling}d.type===de?(a=on(d.props.children,f.mode,k,d.key),a.return=f,f=a):(k=xl(d.type,d.key,d.props,null,f.mode,k),k.ref=ur(f,a,d),k.return=f,f=k)}return i(f);case ge:e:{for(T=d.key;a!==null;){if(a.key===T)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=Si(d,f.mode,k),a.return=f,f=a}return i(f);case ze:return T=d._init,me(f,a,T(d._payload),k)}if(Dn(d))return P(f,a,d,k);if(E(d))return z(f,a,d,k);qr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=ki(d,f.mode,k),a.return=f,f=a),i(f)):n(f,a)}return me}var _n=is(!0),os=is(!1),br=It(null),el=null,Nn=null,Tu=null;function ju(){Tu=Nn=el=null}function Ru(e){var t=br.current;ne(br),e._currentValue=t}function Ou(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Pn(e,t){el=e,Tu=Nn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ve=!0),e.firstContext=null)}function lt(e){var t=e._currentValue;if(Tu!==e)if(e={context:e,memoizedValue:t,next:null},Nn===null){if(el===null)throw Error(h(308));Nn=e,el.dependencies={lanes:0,firstContext:e}}else Nn=Nn.next=e;return t}var bt=null;function Du(e){bt===null?bt=[e]:bt.push(e)}function ss(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Du(t)):(n.next=l.next,l.next=n),t.interleaved=n,_t(e,r)}function _t(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Bt=!1;function Mu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function as(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Nt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function $t(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(B&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,_t(e,n)}return l=r.interleaved,l===null?(t.next=t,Du(r)):(t.next=l.next,l.next=t),r.interleaved=t,_t(e,n)}function tl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gl(e,n)}}function cs(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=i:u=u.next=i,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function nl(e,t,n,r){var l=e.updateQueue;Bt=!1;var u=l.firstBaseUpdate,i=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var s=o,p=s.next;s.next=null,i===null?u=p:i.next=p,i=s;var g=e.alternate;g!==null&&(g=g.updateQueue,o=g.lastBaseUpdate,o!==i&&(o===null?g.firstBaseUpdate=p:o.next=p,g.lastBaseUpdate=s))}if(u!==null){var w=l.baseState;i=0,g=p=s=null,o=u;do{var v=o.lane,C=o.eventTime;if((r&v)===v){g!==null&&(g=g.next={eventTime:C,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var P=e,z=o;switch(v=t,C=n,z.tag){case 1:if(P=z.payload,typeof P=="function"){w=P.call(C,w,v);break e}w=P;break e;case 3:P.flags=P.flags&-65537|128;case 0:if(P=z.payload,v=typeof P=="function"?P.call(C,w,v):P,v==null)break e;w=x({},w,v);break e;case 2:Bt=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,v=l.effects,v===null?l.effects=[o]:v.push(o))}else C={eventTime:C,lane:v,tag:o.tag,payload:o.payload,callback:o.callback,next:null},g===null?(p=g=C,s=w):g=g.next=C,i|=v;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;v=o,o=v.next,v.next=null,l.lastBaseUpdate=v,l.shared.pending=null}}while(!0);if(g===null&&(s=w),l.baseState=s,l.firstBaseUpdate=p,l.lastBaseUpdate=g,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);nn|=i,e.lanes=i,e.memoizedState=w}}function fs(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=$u.transition;$u.transition={};try{e(!1),t()}finally{X=n,$u.transition=r}}function Ts(){return ut().memoizedState}function sf(e,t,n){var r=Wt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},js(e))Rs(t,n);else if(n=ss(e,t,n,r),n!==null){var l=Ue();mt(n,e,r,l),Os(n,t,r)}}function af(e,t,n){var r=Wt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(js(e))Rs(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var i=t.lastRenderedState,o=u(i,n);if(l.hasEagerState=!0,l.eagerState=o,at(o,i)){var s=t.interleaved;s===null?(l.next=l,Du(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=ss(e,t,l,r),n!==null&&(l=Ue(),mt(n,e,r,l),Os(n,t,r))}}function js(e){var t=e.alternate;return e===oe||t!==null&&t===oe}function Rs(e,t){ar=ul=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Os(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gl(e,n)}}var sl={readContext:lt,useCallback:Te,useContext:Te,useEffect:Te,useImperativeHandle:Te,useInsertionEffect:Te,useLayoutEffect:Te,useMemo:Te,useReducer:Te,useRef:Te,useState:Te,useDebugValue:Te,useDeferredValue:Te,useTransition:Te,useMutableSource:Te,useSyncExternalStore:Te,useId:Te,unstable_isNewReconciler:!1},cf={readContext:lt,useCallback:function(e,t){return gt().memoizedState=[e,t===void 0?null:t],e},useContext:lt,useEffect:xs,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,il(4194308,4,_s.bind(null,t,e),n)},useLayoutEffect:function(e,t){return il(4194308,4,e,t)},useInsertionEffect:function(e,t){return il(4,2,e,t)},useMemo:function(e,t){var n=gt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=gt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=sf.bind(null,oe,e),[r.memoizedState,e]},useRef:function(e){var t=gt();return e={current:e},t.memoizedState=e},useState:ks,useDebugValue:Yu,useDeferredValue:function(e){return gt().memoizedState=e},useTransition:function(){var e=ks(!1),t=e[0];return e=of.bind(null,e[1]),gt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=oe,l=gt();if(ue){if(n===void 0)throw Error(h(407));n=n()}else{if(n=t(),Ce===null)throw Error(h(349));(tn&30)!==0||hs(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,xs(ys.bind(null,r,u,e),[e]),r.flags|=2048,dr(9,vs.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=gt(),t=Ce.identifierPrefix;if(ue){var n=Ct,r=Et;n=(r&~(1<<32-st(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=cr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[vt]=t,e[rr]=r,bs(e,t,!1,!1),t.stateNode=e;e:{switch(i=Bl(n,r),n){case"dialog":te("cancel",e),te("close",e),l=r;break;case"iframe":case"object":case"embed":te("load",e),l=r;break;case"video":case"audio":for(l=0;lRn&&(t.flags|=128,r=!0,pr(u,!1),t.lanes=4194304)}else{if(!r)if(e=rl(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),pr(u,!0),u.tail===null&&u.tailMode==="hidden"&&!i.alternate&&!ue)return je(t),null}else 2*pe()-u.renderingStartTime>Rn&&n!==1073741824&&(t.flags|=128,r=!0,pr(u,!1),t.lanes=4194304);u.isBackwards?(i.sibling=t.child,t.child=i):(n=u.last,n!==null?n.sibling=i:t.child=i,u.last=i)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=pe(),t.sibling=null,n=ie.current,b(ie,r?n&1|2:n&1),t):(je(t),null);case 22:case 23:return yi(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(be&1073741824)!==0&&(je(t),t.subtreeFlags&6&&(t.flags|=8192)):je(t),null;case 24:return null;case 25:return null}throw Error(h(156,t.tag))}function gf(e,t){switch(Nu(t),t.tag){case 1:return Ae(t.type)&&Kr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return zn(),ne($e),ne(Le),Bu(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Fu(t),null;case 13:if(ne(ie),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(h(340));Cn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ne(ie),null;case 4:return zn(),null;case 10:return Ru(t.type._context),null;case 22:case 23:return yi(),null;case 24:return null;default:return null}}var dl=!1,Re=!1,wf=typeof WeakSet=="function"?WeakSet:Set,N=null;function Tn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){ae(e,t,r)}else n.current=null}function ui(e,t,n){try{n()}catch(r){ae(e,t,r)}}var na=!1;function kf(e,t){if(yu=Rr,e=Do(),au(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var i=0,o=-1,s=-1,p=0,g=0,w=e,v=null;t:for(;;){for(var C;w!==n||l!==0&&w.nodeType!==3||(o=i+l),w!==u||r!==0&&w.nodeType!==3||(s=i+r),w.nodeType===3&&(i+=w.nodeValue.length),(C=w.firstChild)!==null;)v=w,w=C;for(;;){if(w===e)break t;if(v===n&&++p===l&&(o=i),v===u&&++g===r&&(s=i),(C=w.nextSibling)!==null)break;w=v,v=w.parentNode}w=C}n=o===-1||s===-1?null:{start:o,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(gu={focusedElem:e,selectionRange:n},Rr=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var P=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(P!==null){var z=P.memoizedProps,me=P.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?z:ft(t.type,z),me);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(h(163))}}catch(k){ae(t,t.return,k)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return P=na,na=!1,P}function mr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&ui(t,n,u)}l=l.next}while(l!==r)}}function pl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ii(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function ra(e){var t=e.alternate;t!==null&&(e.alternate=null,ra(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[vt],delete t[rr],delete t[xu],delete t[tf],delete t[nf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function la(e){return e.tag===5||e.tag===3||e.tag===4}function ua(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||la(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function oi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Wr));else if(r!==4&&(e=e.child,e!==null))for(oi(e,t,n),e=e.sibling;e!==null;)oi(e,t,n),e=e.sibling}function si(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(si(e,t,n),e=e.sibling;e!==null;)si(e,t,n),e=e.sibling}var Ne=null,dt=!1;function At(e,t,n){for(n=n.child;n!==null;)ia(e,t,n),n=n.sibling}function ia(e,t,n){if(ht&&typeof ht.onCommitFiberUnmount=="function")try{ht.onCommitFiberUnmount(Nr,n)}catch{}switch(n.tag){case 5:Re||Tn(n,t);case 6:var r=Ne,l=dt;Ne=null,At(e,t,n),Ne=r,dt=l,Ne!==null&&(dt?(e=Ne,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ne.removeChild(n.stateNode));break;case 18:Ne!==null&&(dt?(e=Ne,n=n.stateNode,e.nodeType===8?Su(e.parentNode,n):e.nodeType===1&&Su(e,n),Kn(e)):Su(Ne,n.stateNode));break;case 4:r=Ne,l=dt,Ne=n.stateNode.containerInfo,dt=!0,At(e,t,n),Ne=r,dt=l;break;case 0:case 11:case 14:case 15:if(!Re&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,i=u.destroy;u=u.tag,i!==void 0&&((u&2)!==0||(u&4)!==0)&&ui(n,t,i),l=l.next}while(l!==r)}At(e,t,n);break;case 1:if(!Re&&(Tn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){ae(n,t,o)}At(e,t,n);break;case 21:At(e,t,n);break;case 22:n.mode&1?(Re=(r=Re)||n.memoizedState!==null,At(e,t,n),Re=r):At(e,t,n);break;default:At(e,t,n)}}function oa(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new wf),t.forEach(function(r){var l=Lf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function pt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~u}if(r=l,r=pe()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*xf(r/1960))-r,10e?16:e,Ht===null)var r=!1;else{if(e=Ht,Ht=null,gl=0,(B&6)!==0)throw Error(h(331));var l=B;for(B|=4,N=e.current;N!==null;){var u=N,i=u.child;if((N.flags&16)!==0){var o=u.deletions;if(o!==null){for(var s=0;spe()-fi?ln(e,0):ci|=n),We(e,t)}function ka(e,t){t===0&&((e.mode&1)===0?t=1:(t=zr,zr<<=1,(zr&130023424)===0&&(zr=4194304)));var n=Ue();e=_t(e,t),e!==null&&(An(e,t,n),We(e,n))}function zf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ka(e,n)}function Lf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(h(314))}r!==null&&r.delete(t),ka(e,n)}var Sa;Sa=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||$e.current)Ve=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ve=!1,vf(e,t,n);Ve=(e.flags&131072)!==0}else Ve=!1,ue&&(t.flags&1048576)!==0&&es(t,Zr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;fl(e,t),e=t.pendingProps;var l=Sn(t,Le.current);Pn(t,n),l=Vu(null,t,r,e,l,n);var u=Hu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ae(r)?(u=!0,Yr(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Mu(t),l.updater=al,t.stateNode=l,l._reactInternals=t,Gu(t,r,e,n),t=bu(null,t,r,!0,u,n)):(t.tag=0,ue&&u&&_u(t),Fe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(fl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=jf(r),e=ft(r,e),l){case 0:t=qu(null,t,r,e,n);break e;case 1:t=Ys(null,t,r,e,n);break e;case 11:t=Vs(null,t,r,e,n);break e;case 14:t=Hs(null,t,r,ft(r.type,e),n);break e}throw Error(h(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),qu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Ys(e,t,r,l,n);case 3:e:{if(Xs(t),e===null)throw Error(h(387));r=t.pendingProps,u=t.memoizedState,l=u.element,as(e,t),nl(t,r,null,n);var i=t.memoizedState;if(r=i.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=Ln(Error(h(423)),t),t=Gs(e,t,r,n,l);break e}else if(r!==l){l=Ln(Error(h(424)),t),t=Gs(e,t,r,n,l);break e}else for(qe=Mt(t.stateNode.containerInfo.firstChild),Je=t,ue=!0,ct=null,n=os(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Cn(),r===l){t=Pt(e,t,n);break e}Fe(e,t,r,n)}t=t.child}return t;case 5:return ds(t),e===null&&zu(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,i=l.children,wu(r,l)?i=null:u!==null&&wu(r,u)&&(t.flags|=32),Ks(e,t),Fe(e,t,i,n),t.child;case 6:return e===null&&zu(t),null;case 13:return Zs(e,t,n);case 4:return Iu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=_n(t,null,r,n):Fe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Vs(e,t,r,l,n);case 7:return Fe(e,t,t.pendingProps,n),t.child;case 8:return Fe(e,t,t.pendingProps.children,n),t.child;case 12:return Fe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,i=l.value,b(br,r._currentValue),r._currentValue=i,u!==null)if(at(u.value,i)){if(u.children===l.children&&!$e.current){t=Pt(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var o=u.dependencies;if(o!==null){i=u.child;for(var s=o.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=Nt(-1,n&-n),s.tag=2;var p=u.updateQueue;if(p!==null){p=p.shared;var g=p.pending;g===null?s.next=s:(s.next=g.next,g.next=s),p.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),Ou(u.return,n,t),o.lanes|=n;break}s=s.next}}else if(u.tag===10)i=u.type===t.type?null:u.child;else if(u.tag===18){if(i=u.return,i===null)throw Error(h(341));i.lanes|=n,o=i.alternate,o!==null&&(o.lanes|=n),Ou(i,n,t),i=u.sibling}else i=u.child;if(i!==null)i.return=u;else for(i=u;i!==null;){if(i===t){i=null;break}if(u=i.sibling,u!==null){u.return=i.return,i=u;break}i=i.return}u=i}Fe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Pn(t,n),l=lt(l),r=r(l),t.flags|=1,Fe(e,t,r,n),t.child;case 14:return r=t.type,l=ft(r,t.pendingProps),l=ft(r.type,l),Hs(e,t,r,l,n);case 15:return Ws(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),fl(e,t),t.tag=1,Ae(r)?(e=!0,Yr(t)):e=!1,Pn(t,n),Ms(t,r,l),Gu(t,r,l,n),bu(null,t,r,!0,e,n);case 19:return qs(e,t,n);case 22:return Qs(e,t,n)}throw Error(h(156,t.tag))};function xa(e,t){return eo(e,t)}function Tf(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ot(e,t,n,r){return new Tf(e,t,n,r)}function wi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function jf(e){if(typeof e=="function")return wi(e)?1:0;if(e!=null){if(e=e.$$typeof,e===tt)return 11;if(e===Ie)return 14}return 2}function Kt(e,t){var n=e.alternate;return n===null?(n=ot(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function xl(e,t,n,r,l,u){var i=2;if(r=e,typeof e=="function")wi(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case de:return on(n.children,l,u,t);case we:i=8,l|=8;break;case Ye:return e=ot(12,n,t,l|2),e.elementType=Ye,e.lanes=u,e;case Me:return e=ot(13,n,t,l),e.elementType=Me,e.lanes=u,e;case Xe:return e=ot(19,n,t,l),e.elementType=Xe,e.lanes=u,e;case re:return El(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Be:i=10;break e;case kt:i=9;break e;case tt:i=11;break e;case Ie:i=14;break e;case ze:i=16,r=null;break e}throw Error(h(130,e==null?e:typeof e,""))}return t=ot(i,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function on(e,t,n,r){return e=ot(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=ot(22,e,r,t),e.elementType=re,e.lanes=n,e.stateNode={isHidden:!1},e}function ki(e,t,n){return e=ot(6,e,null,t),e.lanes=n,e}function Si(e,t,n){return t=ot(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Rf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Xl(0),this.expirationTimes=Xl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Xl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function xi(e,t,n,r,l,u,i,o,s){return e=new Rf(e,t,n,o,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=ot(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Mu(u),e}function Of(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(S)}catch(R){console.error(R)}}return S(),zi.exports=Wf(),zi.exports}var Ia;function Kf(){if(Ia)return Tl;Ia=1;var S=Qf();return Tl.createRoot=S.createRoot,Tl.hydrateRoot=S.hydrateRoot,Tl}var Yf=Kf();const Fa=128*1024*1024;async function sn(S,R){var J;const h=await fetch(S,R);if(!h.ok){const D=await h.json().catch(()=>null);throw new Error((D==null?void 0:D.error)??`Request failed with status ${h.status}`)}return h.status===204||!((J=h.headers.get("content-type"))!=null&&J.includes("json"))?void 0:await h.json()}function Rl(S){return`/api/buckets/${encodeURIComponent(S)}`}function Ri(S,R){return`${Rl(S)}/object?key=${encodeURIComponent(R)}`}const Xf=()=>sn("/api/info"),Gf=async()=>(await sn("/api/buckets")).buckets,Zf=S=>sn(Rl(S),{method:"PUT"}),Jf=S=>sn(Rl(S),{method:"DELETE"}),qf=async(S,R)=>(await sn(`${Rl(S)}/objects?prefix=${encodeURIComponent(R)}`)).objects,bf=(S,R,h)=>sn(Ri(S,R),{method:"PUT",body:h,headers:h.type?{"Content-Type":h.type}:void 0}),ed=(S,R)=>sn(Ri(S,R),{method:"DELETE"});function td(S,R){const h=new Set,J=[];for(const D of S){const $=D.key.indexOf("/",R.length);$===-1?J.push(D):h.add(D.key.slice(0,$+1))}return{folders:[...h].sort(),objects:J}}function Ua(S){if(S<1024)return`${S} B`;const R=["KiB","MiB","GiB","TiB"];let h=S,J=-1;for(;h>=1024&&J{const E=new URLSearchParams;m.bucket&&E.set("bucket",m.bucket),m.prefix&&E.set("prefix",m.prefix);const x=E.toString();window.history.pushState(null,"",x?`/?${x}`:"/"),Q(null),fe(m)},[]);ce.useEffect(()=>{const m=()=>{Q(null),fe($a())};return window.addEventListener("popstate",m),()=>window.removeEventListener("popstate",m)},[]);const we=ce.useCallback(async()=>{J(await Gf())},[]),Ye=ce.useCallback(async()=>{if(De.current.bucket!==K||De.current.prefix!==ee)return;const m=++ge.current;try{const E=K?await qf(K,ee):[];m===ge.current&&$(E)}catch(E){if(m===ge.current)throw E}},[K,ee]);ce.useEffect(()=>{Xf().then(R,m=>he(jl(m))),we().catch(m=>he(jl(m)))},[we]),ce.useEffect(()=>{he(""),$([]),Ye().catch(m=>{$([]),he(jl(m))})},[Ye]);const Be=ce.useCallback(async(m,E)=>{et(!0),he(""),Z("");try{await E(),Z(m)}catch(x){he(jl(x))}finally{et(!1),Q(null)}},[]),kt=m=>{m.preventDefault();const E=W.trim();E&&Be(`Created bucket ${E}`,async()=>{await Zf(E),xe(""),await we(),de({bucket:E,prefix:""})})},tt=()=>Be(`Deleted bucket ${K}`,async()=>{await Jf(K),de({bucket:"",prefix:""}),await we()}),Me=m=>Be(`Deleted ${m}`,async()=>{await ed(K,m),await Promise.all([Ye(),we()])}),Xe=m=>{const E=[...m],x=E.find(c=>c.size>Fa);if(x){he(`${x.name} exceeds the ${Ua(Fa)} request limit.`);return}Be(`Uploaded ${E.length} file(s)`,async()=>{for(const c of E)await bf(K,ee+c.name,c);await Promise.all([Ye(),we()])})},Ie=ce.useMemo(()=>td(D,ee),[D,ee]),ze=ce.useMemo(()=>{const m=ee.split("/").filter(Boolean);return m.map((E,x)=>({label:E,prefix:`${m.slice(0,x+1).join("/")}/`}))},[ee]),re=Ie.folders.length===0&&Ie.objects.length===0;return _.jsxs("div",{className:"app",children:[_.jsxs("header",{className:"topbar",children:[_.jsx("h1",{children:"devcloud S3 console"}),S&&_.jsxs("span",{className:"meta",children:[S.region," ยท ",S.authMode," auth"]})]}),_.jsxs("div",{className:"layout",children:[_.jsxs("nav",{className:"sidebar","aria-label":"Buckets",children:[_.jsxs("form",{className:"new-bucket",onSubmit:kt,children:[_.jsx("label",{htmlFor:"new-bucket",children:"New bucket"}),_.jsxs("div",{className:"row",children:[_.jsx("input",{id:"new-bucket",value:W,onChange:m=>xe(m.target.value),placeholder:"my-bucket",autoComplete:"off",spellCheck:!1}),_.jsx("button",{className:"button primary",disabled:U||!W.trim(),children:"Create"})]})]}),_.jsx("ul",{className:"bucket-list",children:h.map(m=>_.jsx("li",{children:_.jsxs("button",{className:m.name===K?"bucket active":"bucket","aria-current":m.name===K?"true":void 0,onClick:()=>de({bucket:m.name,prefix:""}),children:[_.jsx("span",{className:"bucket-name",children:m.name}),_.jsx("span",{className:"meta",children:m.objectCount})]})},m.name))}),h.length===0&&_.jsx("p",{className:"meta empty-note",children:"No buckets yet."})]}),_.jsxs("main",{className:"content",children:[_.jsxs("div",{"aria-live":"polite",children:[G&&_.jsx("p",{className:"banner error",role:"alert",children:G}),!G&&Oe&&_.jsx("p",{className:"banner",children:Oe})]}),!K&&_.jsx("p",{className:"placeholder",children:"Select or create a bucket."}),K&&_.jsxs(_.Fragment,{children:[_.jsxs("div",{className:"toolbar",children:[_.jsxs("nav",{className:"crumbs","aria-label":"Path",children:[_.jsx("button",{className:"link",onClick:()=>de({bucket:K,prefix:""}),children:K}),ze.map(m=>_.jsxs("span",{children:[_.jsx("span",{className:"separator",children:"/"}),_.jsx("button",{className:"link",onClick:()=>de({bucket:K,prefix:m.prefix}),children:m.label})]},m.prefix))]}),_.jsxs("div",{className:"row",children:[_.jsx("input",{ref:Ke,type:"file",multiple:!0,hidden:!0,"aria-label":"Files to upload",onChange:m=>{var E;(E=m.target.files)!=null&&E.length&&Xe(m.target.files),m.target.value=""}}),_.jsx("button",{className:"button primary",disabled:U,onClick:()=>{var m;return(m=Ke.current)==null?void 0:m.click()},children:"Upload"}),ye===K?_.jsxs(_.Fragment,{children:[_.jsx("button",{className:"button danger primary",disabled:U,onClick:()=>void tt(),children:"Confirm delete bucket"}),_.jsx("button",{className:"button",onClick:()=>Q(null),children:"Cancel"})]}):_.jsx("button",{className:"button danger",disabled:U,onClick:()=>Q(K),children:"Delete bucket"})]})]}),re?_.jsx("p",{className:"placeholder",children:"This location is empty."}):_.jsxs("table",{className:"objects",children:[_.jsx("thead",{children:_.jsxs("tr",{children:[_.jsx("th",{scope:"col",children:"Name"}),_.jsx("th",{scope:"col",className:"numeric",children:"Size"}),_.jsx("th",{scope:"col",children:"Type"}),_.jsx("th",{scope:"col",children:"Last modified"}),_.jsx("th",{scope:"col",children:_.jsx("span",{className:"sr-only",children:"Actions"})})]})}),_.jsxs("tbody",{children:[Ie.folders.map(m=>_.jsx("tr",{children:_.jsx("td",{colSpan:5,children:_.jsxs("button",{className:"link",onClick:()=>de({bucket:K,prefix:m}),children:[Ba(m),"/"]})})},m)),Ie.objects.map(m=>_.jsxs("tr",{children:[_.jsx("td",{className:"name",children:Ba(m.key)}),_.jsx("td",{className:"numeric",children:Ua(m.size)}),_.jsx("td",{className:"meta",children:m.contentType}),_.jsx("td",{className:"meta",children:nd(m.lastModified)}),_.jsx("td",{className:"actions",children:ye===m.key?_.jsxs(_.Fragment,{children:[_.jsx("button",{className:"button danger primary",disabled:U,onClick:()=>void Me(m.key),children:"Confirm"}),_.jsx("button",{className:"button",onClick:()=>Q(null),children:"Cancel"})]}):_.jsxs(_.Fragment,{children:[_.jsx("a",{className:"button",href:Ri(K,m.key),download:!0,children:"Download"}),_.jsx("button",{className:"button danger",disabled:U,onClick:()=>Q(m.key),children:"Delete"})]})})]},m.key))]})]})]})]})]})]})}const Aa=document.getElementById("root");if(!Aa)throw new Error("Console root element is missing");Yf.createRoot(Aa).render(_.jsx(ce.StrictMode,{children:_.jsx(rd,{})})); diff --git a/services/s3/assets/console/assets/index-DC6_bWwi.js b/services/s3/assets/console/assets/index-DC6_bWwi.js new file mode 100644 index 0000000..846368c --- /dev/null +++ b/services/s3/assets/console/assets/index-DC6_bWwi.js @@ -0,0 +1,40 @@ +(function(){const j=document.createElement("link").relList;if(j&&j.supports&&j.supports("modulepreload"))return;for(const D of document.querySelectorAll('link[rel="modulepreload"]'))q(D);new MutationObserver(D=>{for(const A of D)if(A.type==="childList")for(const se of A.addedNodes)se.tagName==="LINK"&&se.rel==="modulepreload"&&q(se)}).observe(document,{childList:!0,subtree:!0});function h(D){const A={};return D.integrity&&(A.integrity=D.integrity),D.referrerPolicy&&(A.referrerPolicy=D.referrerPolicy),D.crossOrigin==="use-credentials"?A.credentials="include":D.crossOrigin==="anonymous"?A.credentials="omit":A.credentials="same-origin",A}function q(D){if(D.ep)return;D.ep=!0;const A=h(D);fetch(D.href,A)}})();var Ni={exports:{}},wr={},Pi={exports:{}},I={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var za;function Bf(){if(za)return I;za=1;var S=Symbol.for("react.element"),j=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),q=Symbol.for("react.strict_mode"),D=Symbol.for("react.profiler"),A=Symbol.for("react.provider"),se=Symbol.for("react.context"),fe=Symbol.for("react.forward_ref"),W=Symbol.for("react.suspense"),xe=Symbol.for("react.memo"),ye=Symbol.for("react.lazy"),Q=Symbol.iterator;function G(c){return c===null||typeof c!="object"?null:(c=Q&&c[Q]||c["@@iterator"],typeof c=="function"?c:null)}var he={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Oe=Object.assign,J={};function U(c,y,M){this.props=c,this.context=y,this.refs=J,this.updater=M||he}U.prototype.isReactComponent={},U.prototype.setState=function(c,y){if(typeof c!="object"&&typeof c!="function"&&c!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,c,y,"setState")},U.prototype.forceUpdate=function(c){this.updater.enqueueForceUpdate(this,c,"forceUpdate")};function et(){}et.prototype=U.prototype;function Ke(c,y,M){this.props=c,this.context=y,this.refs=J,this.updater=M||he}var K=Ke.prototype=new et;K.constructor=Ke,Oe(K,U.prototype),K.isPureReactComponent=!0;var Z=Array.isArray,De=Object.prototype.hasOwnProperty,ge={current:null},de={key:!0,ref:!0,__self:!0,__source:!0};function we(c,y,M){var F,$={},V=null,b=null;if(y!=null)for(F in y.ref!==void 0&&(b=y.ref),y.key!==void 0&&(V=""+y.key),y)De.call(y,F)&&!de.hasOwnProperty(F)&&($[F]=y[F]);var Y=arguments.length-2;if(Y===1)$.children=M;else if(1>>1,y=m[c];if(0>>1;cD($,x))VD(b,$)?(m[c]=b,m[V]=x,c=V):(m[c]=$,m[F]=x,c=F);else if(VD(b,x))m[c]=b,m[V]=x,c=V;else break e}}return E}function D(m,E){var x=m.sortIndex-E.sortIndex;return x!==0?x:m.id-E.id}if(typeof performance=="object"&&typeof performance.now=="function"){var A=performance;S.unstable_now=function(){return A.now()}}else{var se=Date,fe=se.now();S.unstable_now=function(){return se.now()-fe}}var W=[],xe=[],ye=1,Q=null,G=3,he=!1,Oe=!1,J=!1,U=typeof setTimeout=="function"?setTimeout:null,et=typeof clearTimeout=="function"?clearTimeout:null,Ke=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function K(m){for(var E=h(xe);E!==null;){if(E.callback===null)q(xe);else if(E.startTime<=m)q(xe),E.sortIndex=E.expirationTime,j(W,E);else break;E=h(xe)}}function Z(m){if(J=!1,K(m),!Oe)if(h(W)!==null)Oe=!0,ze(De);else{var E=h(xe);E!==null&&re(Z,E.startTime-m)}}function De(m,E){Oe=!1,J&&(J=!1,et(we),we=-1),he=!0;var x=G;try{for(K(E),Q=h(W);Q!==null&&(!(Q.expirationTime>E)||m&&!kt());){var c=Q.callback;if(typeof c=="function"){Q.callback=null,G=Q.priorityLevel;var y=c(Q.expirationTime<=E);E=S.unstable_now(),typeof y=="function"?Q.callback=y:Q===h(W)&&q(W),K(E)}else q(W);Q=h(W)}if(Q!==null)var M=!0;else{var F=h(xe);F!==null&&re(Z,F.startTime-E),M=!1}return M}finally{Q=null,G=x,he=!1}}var ge=!1,de=null,we=-1,Ye=5,Be=-1;function kt(){return!(S.unstable_now()-Bem||125c?(m.sortIndex=x,j(xe,m),h(W)===null&&m===h(xe)&&(J?(et(we),we=-1):J=!0,re(Z,x-c))):(m.sortIndex=y,j(W,m),Oe||he||(Oe=!0,ze(De))),m},S.unstable_shouldYield=kt,S.unstable_wrapCallback=function(m){var E=G;return function(){var x=G;G=E;try{return m.apply(this,arguments)}finally{G=x}}}})(Ti)),Ti}var Oa;function Hf(){return Oa||(Oa=1,Li.exports=Vf()),Li.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Da;function Wf(){if(Da)return Qe;Da=1;var S=Ri(),j=Hf();function h(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),W=Object.prototype.hasOwnProperty,xe=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ye={},Q={};function G(e){return W.call(Q,e)?!0:W.call(ye,e)?!1:xe.test(e)?Q[e]=!0:(ye[e]=!0,!1)}function he(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Oe(e,t,n,r){if(t===null||typeof t>"u"||he(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function J(e,t,n,r,l,u,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=i}var U={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){U[e]=new J(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];U[t]=new J(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){U[e]=new J(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){U[e]=new J(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){U[e]=new J(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){U[e]=new J(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){U[e]=new J(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){U[e]=new J(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){U[e]=new J(e,5,!1,e.toLowerCase(),null,!1,!1)});var et=/[\-:]([a-z])/g;function Ke(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(et,Ke);U[t]=new J(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(et,Ke);U[t]=new J(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(et,Ke);U[t]=new J(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){U[e]=new J(e,1,!1,e.toLowerCase(),null,!1,!1)}),U.xlinkHref=new J("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){U[e]=new J(e,1,!1,e.toLowerCase(),null,!0,!0)});function K(e,t,n,r){var l=U.hasOwnProperty(t)?U[t]:null;(l!==null?l.type!==0:r||!(2o||l[i]!==u[o]){var s=` +`+l[i].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=i&&0<=o);break}}}finally{M=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?y(e):""}function $(e){switch(e.tag){case 5:return y(e.type);case 16:return y("Lazy");case 13:return y("Suspense");case 19:return y("SuspenseList");case 0:case 2:case 15:return e=F(e.type,!1),e;case 11:return e=F(e.type.render,!1),e;case 1:return e=F(e.type,!0),e;default:return""}}function V(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case de:return"Fragment";case ge:return"Portal";case Ye:return"Profiler";case we:return"StrictMode";case Me:return"Suspense";case Xe:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case kt:return(e.displayName||"Context")+".Consumer";case Be:return(e._context.displayName||"Context")+".Provider";case tt:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ie:return t=e.displayName||null,t!==null?t:V(e.type)||"Memo";case ze:t=e._payload,e=e._init;try{return V(e(t))}catch{}}return null}function b(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return V(t);case 8:return t===we?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Y(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function le(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ge(e){var t=le(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,u.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function kr(e){e._valueTracker||(e._valueTracker=Ge(e))}function Oi(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=le(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Sr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Ol(e,t){var n=t.checked;return x({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Di(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Y(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Mi(e,t){t=t.checked,t!=null&&K(e,"checked",t,!1)}function Dl(e,t){Mi(e,t);var n=Y(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ml(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ml(e,t.type,Y(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ii(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ml(e,t,n){(t!=="number"||Sr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Dn=Array.isArray;function an(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=xr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Mn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var In={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Va=["Webkit","ms","Moz","O"];Object.keys(In).forEach(function(e){Va.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),In[t]=In[e]})});function Vi(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||In.hasOwnProperty(e)&&In[e]?(""+t).trim():t+"px"}function Hi(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Vi(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Ha=x({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Ul(e,t){if(t){if(Ha[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(h(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(h(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(h(61))}if(t.style!=null&&typeof t.style!="object")throw Error(h(62))}}function Bl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Al=null;function $l(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Vl=null,cn=null,fn=null;function Wi(e){if(e=lr(e)){if(typeof Vl!="function")throw Error(h(280));var t=e.stateNode;t&&(t=Qr(t),Vl(e.stateNode,e.type,t))}}function Qi(e){cn?fn?fn.push(e):fn=[e]:cn=e}function Ki(){if(cn){var e=cn,t=fn;if(fn=cn=null,Wi(e),t)for(e=0;e>>=0,e===0?32:31-(ec(e)/tc|0)|0}var Pr=64,zr=4194304;function An(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Lr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,i=n&268435455;if(i!==0){var o=i&~l;o!==0?r=An(o):(u&=i,u!==0&&(r=An(u)))}else i=n&~l,i!==0?r=An(i):u!==0&&(r=An(u));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function $n(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-st(t),e[t]=n}function uc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Gn),So=" ",xo=!1;function Eo(e,t){switch(e){case"keyup":return Oc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Co(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var mn=!1;function Mc(e,t){switch(e){case"compositionend":return Co(t);case"keypress":return t.which!==32?null:(xo=!0,So);case"textInput":return e=t.data,e===So&&xo?null:e;default:return null}}function Ic(e,t){if(mn)return e==="compositionend"||!iu&&Eo(e,t)?(e=ho(),Dr=eu=Ot=null,mn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Ro(n)}}function Oo(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Oo(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Do(){for(var e=window,t=Sr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Sr(e.document)}return t}function au(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Qc(e){var t=Do(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Oo(n.ownerDocument.documentElement,n)){if(r!==null&&au(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=jo(n,u);var i=jo(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,hn=null,cu=null,bn=null,fu=!1;function Mo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;fu||hn==null||hn!==Sr(r)||(r=hn,"selectionStart"in r&&au(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),bn&&qn(bn,r)||(bn=r,r=Vr(cu,"onSelect"),0kn||(e.current=Eu[kn],Eu[kn]=null,kn--)}function ee(e,t){kn++,Eu[kn]=e.current,e.current=t}var Ft={},Le=It(Ft),Ae=It(!1),Zt=Ft;function Sn(e,t){var n=e.type.contextTypes;if(!n)return Ft;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function $e(e){return e=e.childContextTypes,e!=null}function Kr(){ne(Ae),ne(Le)}function Zo(e,t,n){if(Le.current!==Ft)throw Error(h(168));ee(Le,t),ee(Ae,n)}function Jo(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(h(108,b(e)||"Unknown",l));return x({},n,r)}function Yr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ft,Zt=Le.current,ee(Le,e),ee(Ae,Ae.current),!0}function qo(e,t,n){var r=e.stateNode;if(!r)throw Error(h(169));n?(e=Jo(e,t,Zt),r.__reactInternalMemoizedMergedChildContext=e,ne(Ae),ne(Le),ee(Le,e)):ne(Ae),ee(Ae,n)}var xt=null,Xr=!1,Cu=!1;function bo(e){xt===null?xt=[e]:xt.push(e)}function rf(e){Xr=!0,bo(e)}function Ut(){if(!Cu&&xt!==null){Cu=!0;var e=0,t=X;try{var n=xt;for(X=1;e>=i,l-=i,Et=1<<32-st(t)+l|n<O?(_e=R,R=null):_e=R.sibling;var H=v(f,R,d[O],k);if(H===null){R===null&&(R=_e);break}e&&R&&H.alternate===null&&t(f,R),a=u(H,a,O),T===null?L=H:T.sibling=H,T=H,R=_e}if(O===d.length)return n(f,R),ue&&qt(f,O),L;if(R===null){for(;OO?(_e=R,R=null):_e=R.sibling;var Yt=v(f,R,H.value,k);if(Yt===null){R===null&&(R=_e);break}e&&R&&Yt.alternate===null&&t(f,R),a=u(Yt,a,O),T===null?L=Yt:T.sibling=Yt,T=Yt,R=_e}if(H.done)return n(f,R),ue&&qt(f,O),L;if(R===null){for(;!H.done;O++,H=d.next())H=w(f,H.value,k),H!==null&&(a=u(H,a,O),T===null?L=H:T.sibling=H,T=H);return ue&&qt(f,O),L}for(R=r(f,R);!H.done;O++,H=d.next())H=C(R,f,O,H.value,k),H!==null&&(e&&H.alternate!==null&&R.delete(H.key===null?O:H.key),a=u(H,a,O),T===null?L=H:T.sibling=H,T=H);return e&&R.forEach(function(Uf){return t(f,Uf)}),ue&&qt(f,O),L}function me(f,a,d,k){if(typeof d=="object"&&d!==null&&d.type===de&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case De:e:{for(var L=d.key,T=a;T!==null;){if(T.key===L){if(L=d.type,L===de){if(T.tag===7){n(f,T.sibling),a=l(T,d.props.children),a.return=f,f=a;break e}}else if(T.elementType===L||typeof L=="object"&&L!==null&&L.$$typeof===ze&&us(L)===T.type){n(f,T.sibling),a=l(T,d.props),a.ref=ur(f,T,d),a.return=f,f=a;break e}n(f,T);break}else t(f,T);T=T.sibling}d.type===de?(a=on(d.props.children,f.mode,k,d.key),a.return=f,f=a):(k=xl(d.type,d.key,d.props,null,f.mode,k),k.ref=ur(f,a,d),k.return=f,f=k)}return i(f);case ge:e:{for(T=d.key;a!==null;){if(a.key===T)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=Si(d,f.mode,k),a.return=f,f=a}return i(f);case ze:return T=d._init,me(f,a,T(d._payload),k)}if(Dn(d))return P(f,a,d,k);if(E(d))return z(f,a,d,k);qr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=ki(d,f.mode,k),a.return=f,f=a),i(f)):n(f,a)}return me}var _n=is(!0),os=is(!1),br=It(null),el=null,Nn=null,Tu=null;function Ru(){Tu=Nn=el=null}function ju(e){var t=br.current;ne(br),e._currentValue=t}function Ou(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Pn(e,t){el=e,Tu=Nn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ve=!0),e.firstContext=null)}function lt(e){var t=e._currentValue;if(Tu!==e)if(e={context:e,memoizedValue:t,next:null},Nn===null){if(el===null)throw Error(h(308));Nn=e,el.dependencies={lanes:0,firstContext:e}}else Nn=Nn.next=e;return t}var bt=null;function Du(e){bt===null?bt=[e]:bt.push(e)}function ss(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Du(t)):(n.next=l.next,l.next=n),t.interleaved=n,_t(e,r)}function _t(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Bt=!1;function Mu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function as(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Nt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function At(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(B&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,_t(e,n)}return l=r.interleaved,l===null?(t.next=t,Du(r)):(t.next=l.next,l.next=t),r.interleaved=t,_t(e,n)}function tl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gl(e,n)}}function cs(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=i:u=u.next=i,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function nl(e,t,n,r){var l=e.updateQueue;Bt=!1;var u=l.firstBaseUpdate,i=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var s=o,p=s.next;s.next=null,i===null?u=p:i.next=p,i=s;var g=e.alternate;g!==null&&(g=g.updateQueue,o=g.lastBaseUpdate,o!==i&&(o===null?g.firstBaseUpdate=p:o.next=p,g.lastBaseUpdate=s))}if(u!==null){var w=l.baseState;i=0,g=p=s=null,o=u;do{var v=o.lane,C=o.eventTime;if((r&v)===v){g!==null&&(g=g.next={eventTime:C,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var P=e,z=o;switch(v=t,C=n,z.tag){case 1:if(P=z.payload,typeof P=="function"){w=P.call(C,w,v);break e}w=P;break e;case 3:P.flags=P.flags&-65537|128;case 0:if(P=z.payload,v=typeof P=="function"?P.call(C,w,v):P,v==null)break e;w=x({},w,v);break e;case 2:Bt=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,v=l.effects,v===null?l.effects=[o]:v.push(o))}else C={eventTime:C,lane:v,tag:o.tag,payload:o.payload,callback:o.callback,next:null},g===null?(p=g=C,s=w):g=g.next=C,i|=v;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;v=o,o=v.next,v.next=null,l.lastBaseUpdate=v,l.shared.pending=null}}while(!0);if(g===null&&(s=w),l.baseState=s,l.firstBaseUpdate=p,l.lastBaseUpdate=g,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);nn|=i,e.lanes=i,e.memoizedState=w}}function fs(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Au.transition;Au.transition={};try{e(!1),t()}finally{X=n,Au.transition=r}}function Ts(){return ut().memoizedState}function sf(e,t,n){var r=Wt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Rs(e))js(t,n);else if(n=ss(e,t,n,r),n!==null){var l=Ue();mt(n,e,r,l),Os(n,t,r)}}function af(e,t,n){var r=Wt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Rs(e))js(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var i=t.lastRenderedState,o=u(i,n);if(l.hasEagerState=!0,l.eagerState=o,at(o,i)){var s=t.interleaved;s===null?(l.next=l,Du(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=ss(e,t,l,r),n!==null&&(l=Ue(),mt(n,e,r,l),Os(n,t,r))}}function Rs(e){var t=e.alternate;return e===oe||t!==null&&t===oe}function js(e,t){ar=ul=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Os(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gl(e,n)}}var sl={readContext:lt,useCallback:Te,useContext:Te,useEffect:Te,useImperativeHandle:Te,useInsertionEffect:Te,useLayoutEffect:Te,useMemo:Te,useReducer:Te,useRef:Te,useState:Te,useDebugValue:Te,useDeferredValue:Te,useTransition:Te,useMutableSource:Te,useSyncExternalStore:Te,useId:Te,unstable_isNewReconciler:!1},cf={readContext:lt,useCallback:function(e,t){return gt().memoizedState=[e,t===void 0?null:t],e},useContext:lt,useEffect:xs,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,il(4194308,4,_s.bind(null,t,e),n)},useLayoutEffect:function(e,t){return il(4194308,4,e,t)},useInsertionEffect:function(e,t){return il(4,2,e,t)},useMemo:function(e,t){var n=gt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=gt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=sf.bind(null,oe,e),[r.memoizedState,e]},useRef:function(e){var t=gt();return e={current:e},t.memoizedState=e},useState:ks,useDebugValue:Yu,useDeferredValue:function(e){return gt().memoizedState=e},useTransition:function(){var e=ks(!1),t=e[0];return e=of.bind(null,e[1]),gt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=oe,l=gt();if(ue){if(n===void 0)throw Error(h(407));n=n()}else{if(n=t(),Ce===null)throw Error(h(349));(tn&30)!==0||hs(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,xs(ys.bind(null,r,u,e),[e]),r.flags|=2048,dr(9,vs.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=gt(),t=Ce.identifierPrefix;if(ue){var n=Ct,r=Et;n=(r&~(1<<32-st(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=cr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[vt]=t,e[rr]=r,bs(e,t,!1,!1),t.stateNode=e;e:{switch(i=Bl(n,r),n){case"dialog":te("cancel",e),te("close",e),l=r;break;case"iframe":case"object":case"embed":te("load",e),l=r;break;case"video":case"audio":for(l=0;ljn&&(t.flags|=128,r=!0,pr(u,!1),t.lanes=4194304)}else{if(!r)if(e=rl(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),pr(u,!0),u.tail===null&&u.tailMode==="hidden"&&!i.alternate&&!ue)return Re(t),null}else 2*pe()-u.renderingStartTime>jn&&n!==1073741824&&(t.flags|=128,r=!0,pr(u,!1),t.lanes=4194304);u.isBackwards?(i.sibling=t.child,t.child=i):(n=u.last,n!==null?n.sibling=i:t.child=i,u.last=i)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=pe(),t.sibling=null,n=ie.current,ee(ie,r?n&1|2:n&1),t):(Re(t),null);case 22:case 23:return yi(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(be&1073741824)!==0&&(Re(t),t.subtreeFlags&6&&(t.flags|=8192)):Re(t),null;case 24:return null;case 25:return null}throw Error(h(156,t.tag))}function gf(e,t){switch(Nu(t),t.tag){case 1:return $e(t.type)&&Kr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return zn(),ne(Ae),ne(Le),Bu(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Fu(t),null;case 13:if(ne(ie),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(h(340));Cn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ne(ie),null;case 4:return zn(),null;case 10:return ju(t.type._context),null;case 22:case 23:return yi(),null;case 24:return null;default:return null}}var dl=!1,je=!1,wf=typeof WeakSet=="function"?WeakSet:Set,N=null;function Tn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){ae(e,t,r)}else n.current=null}function ui(e,t,n){try{n()}catch(r){ae(e,t,r)}}var na=!1;function kf(e,t){if(yu=jr,e=Do(),au(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var i=0,o=-1,s=-1,p=0,g=0,w=e,v=null;t:for(;;){for(var C;w!==n||l!==0&&w.nodeType!==3||(o=i+l),w!==u||r!==0&&w.nodeType!==3||(s=i+r),w.nodeType===3&&(i+=w.nodeValue.length),(C=w.firstChild)!==null;)v=w,w=C;for(;;){if(w===e)break t;if(v===n&&++p===l&&(o=i),v===u&&++g===r&&(s=i),(C=w.nextSibling)!==null)break;w=v,v=w.parentNode}w=C}n=o===-1||s===-1?null:{start:o,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(gu={focusedElem:e,selectionRange:n},jr=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var P=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(P!==null){var z=P.memoizedProps,me=P.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?z:ft(t.type,z),me);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(h(163))}}catch(k){ae(t,t.return,k)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return P=na,na=!1,P}function mr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&ui(t,n,u)}l=l.next}while(l!==r)}}function pl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ii(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function ra(e){var t=e.alternate;t!==null&&(e.alternate=null,ra(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[vt],delete t[rr],delete t[xu],delete t[tf],delete t[nf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function la(e){return e.tag===5||e.tag===3||e.tag===4}function ua(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||la(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function oi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Wr));else if(r!==4&&(e=e.child,e!==null))for(oi(e,t,n),e=e.sibling;e!==null;)oi(e,t,n),e=e.sibling}function si(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(si(e,t,n),e=e.sibling;e!==null;)si(e,t,n),e=e.sibling}var Ne=null,dt=!1;function $t(e,t,n){for(n=n.child;n!==null;)ia(e,t,n),n=n.sibling}function ia(e,t,n){if(ht&&typeof ht.onCommitFiberUnmount=="function")try{ht.onCommitFiberUnmount(Nr,n)}catch{}switch(n.tag){case 5:je||Tn(n,t);case 6:var r=Ne,l=dt;Ne=null,$t(e,t,n),Ne=r,dt=l,Ne!==null&&(dt?(e=Ne,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ne.removeChild(n.stateNode));break;case 18:Ne!==null&&(dt?(e=Ne,n=n.stateNode,e.nodeType===8?Su(e.parentNode,n):e.nodeType===1&&Su(e,n),Kn(e)):Su(Ne,n.stateNode));break;case 4:r=Ne,l=dt,Ne=n.stateNode.containerInfo,dt=!0,$t(e,t,n),Ne=r,dt=l;break;case 0:case 11:case 14:case 15:if(!je&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,i=u.destroy;u=u.tag,i!==void 0&&((u&2)!==0||(u&4)!==0)&&ui(n,t,i),l=l.next}while(l!==r)}$t(e,t,n);break;case 1:if(!je&&(Tn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){ae(n,t,o)}$t(e,t,n);break;case 21:$t(e,t,n);break;case 22:n.mode&1?(je=(r=je)||n.memoizedState!==null,$t(e,t,n),je=r):$t(e,t,n);break;default:$t(e,t,n)}}function oa(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new wf),t.forEach(function(r){var l=Lf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function pt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~u}if(r=l,r=pe()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*xf(r/1960))-r,10e?16:e,Ht===null)var r=!1;else{if(e=Ht,Ht=null,gl=0,(B&6)!==0)throw Error(h(331));var l=B;for(B|=4,N=e.current;N!==null;){var u=N,i=u.child;if((N.flags&16)!==0){var o=u.deletions;if(o!==null){for(var s=0;spe()-fi?ln(e,0):ci|=n),We(e,t)}function ka(e,t){t===0&&((e.mode&1)===0?t=1:(t=zr,zr<<=1,(zr&130023424)===0&&(zr=4194304)));var n=Ue();e=_t(e,t),e!==null&&($n(e,t,n),We(e,n))}function zf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ka(e,n)}function Lf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(h(314))}r!==null&&r.delete(t),ka(e,n)}var Sa;Sa=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ae.current)Ve=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ve=!1,vf(e,t,n);Ve=(e.flags&131072)!==0}else Ve=!1,ue&&(t.flags&1048576)!==0&&es(t,Zr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;fl(e,t),e=t.pendingProps;var l=Sn(t,Le.current);Pn(t,n),l=Vu(null,t,r,e,l,n);var u=Hu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,$e(r)?(u=!0,Yr(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Mu(t),l.updater=al,t.stateNode=l,l._reactInternals=t,Gu(t,r,e,n),t=bu(null,t,r,!0,u,n)):(t.tag=0,ue&&u&&_u(t),Fe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(fl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Rf(r),e=ft(r,e),l){case 0:t=qu(null,t,r,e,n);break e;case 1:t=Ys(null,t,r,e,n);break e;case 11:t=Vs(null,t,r,e,n);break e;case 14:t=Hs(null,t,r,ft(r.type,e),n);break e}throw Error(h(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),qu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Ys(e,t,r,l,n);case 3:e:{if(Xs(t),e===null)throw Error(h(387));r=t.pendingProps,u=t.memoizedState,l=u.element,as(e,t),nl(t,r,null,n);var i=t.memoizedState;if(r=i.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=Ln(Error(h(423)),t),t=Gs(e,t,r,n,l);break e}else if(r!==l){l=Ln(Error(h(424)),t),t=Gs(e,t,r,n,l);break e}else for(qe=Mt(t.stateNode.containerInfo.firstChild),Je=t,ue=!0,ct=null,n=os(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Cn(),r===l){t=Pt(e,t,n);break e}Fe(e,t,r,n)}t=t.child}return t;case 5:return ds(t),e===null&&zu(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,i=l.children,wu(r,l)?i=null:u!==null&&wu(r,u)&&(t.flags|=32),Ks(e,t),Fe(e,t,i,n),t.child;case 6:return e===null&&zu(t),null;case 13:return Zs(e,t,n);case 4:return Iu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=_n(t,null,r,n):Fe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Vs(e,t,r,l,n);case 7:return Fe(e,t,t.pendingProps,n),t.child;case 8:return Fe(e,t,t.pendingProps.children,n),t.child;case 12:return Fe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,i=l.value,ee(br,r._currentValue),r._currentValue=i,u!==null)if(at(u.value,i)){if(u.children===l.children&&!Ae.current){t=Pt(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var o=u.dependencies;if(o!==null){i=u.child;for(var s=o.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=Nt(-1,n&-n),s.tag=2;var p=u.updateQueue;if(p!==null){p=p.shared;var g=p.pending;g===null?s.next=s:(s.next=g.next,g.next=s),p.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),Ou(u.return,n,t),o.lanes|=n;break}s=s.next}}else if(u.tag===10)i=u.type===t.type?null:u.child;else if(u.tag===18){if(i=u.return,i===null)throw Error(h(341));i.lanes|=n,o=i.alternate,o!==null&&(o.lanes|=n),Ou(i,n,t),i=u.sibling}else i=u.child;if(i!==null)i.return=u;else for(i=u;i!==null;){if(i===t){i=null;break}if(u=i.sibling,u!==null){u.return=i.return,i=u;break}i=i.return}u=i}Fe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Pn(t,n),l=lt(l),r=r(l),t.flags|=1,Fe(e,t,r,n),t.child;case 14:return r=t.type,l=ft(r,t.pendingProps),l=ft(r.type,l),Hs(e,t,r,l,n);case 15:return Ws(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),fl(e,t),t.tag=1,$e(r)?(e=!0,Yr(t)):e=!1,Pn(t,n),Ms(t,r,l),Gu(t,r,l,n),bu(null,t,r,!0,e,n);case 19:return qs(e,t,n);case 22:return Qs(e,t,n)}throw Error(h(156,t.tag))};function xa(e,t){return eo(e,t)}function Tf(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ot(e,t,n,r){return new Tf(e,t,n,r)}function wi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Rf(e){if(typeof e=="function")return wi(e)?1:0;if(e!=null){if(e=e.$$typeof,e===tt)return 11;if(e===Ie)return 14}return 2}function Kt(e,t){var n=e.alternate;return n===null?(n=ot(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function xl(e,t,n,r,l,u){var i=2;if(r=e,typeof e=="function")wi(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case de:return on(n.children,l,u,t);case we:i=8,l|=8;break;case Ye:return e=ot(12,n,t,l|2),e.elementType=Ye,e.lanes=u,e;case Me:return e=ot(13,n,t,l),e.elementType=Me,e.lanes=u,e;case Xe:return e=ot(19,n,t,l),e.elementType=Xe,e.lanes=u,e;case re:return El(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Be:i=10;break e;case kt:i=9;break e;case tt:i=11;break e;case Ie:i=14;break e;case ze:i=16,r=null;break e}throw Error(h(130,e==null?e:typeof e,""))}return t=ot(i,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function on(e,t,n,r){return e=ot(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=ot(22,e,r,t),e.elementType=re,e.lanes=n,e.stateNode={isHidden:!1},e}function ki(e,t,n){return e=ot(6,e,null,t),e.lanes=n,e}function Si(e,t,n){return t=ot(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function jf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Xl(0),this.expirationTimes=Xl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Xl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function xi(e,t,n,r,l,u,i,o,s){return e=new jf(e,t,n,o,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=ot(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Mu(u),e}function Of(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(S)}catch(j){console.error(j)}}return S(),zi.exports=Wf(),zi.exports}var Ia;function Kf(){if(Ia)return Tl;Ia=1;var S=Qf();return Tl.createRoot=S.createRoot,Tl.hydrateRoot=S.hydrateRoot,Tl}var Yf=Kf();const Fa=128*1024*1024;async function sn(S,j){var q;const h=await fetch(S,j);if(!h.ok){const D=await h.json().catch(()=>null);throw new Error((D==null?void 0:D.error)??`Request failed with status ${h.status}`)}return h.status===204||!((q=h.headers.get("content-type"))!=null&&q.includes("json"))?void 0:await h.json()}function jl(S){return`/api/buckets/${encodeURIComponent(S)}`}function ji(S,j){return`${jl(S)}/object?key=${encodeURIComponent(j)}`}const Xf=()=>sn("/api/info"),Gf=async()=>(await sn("/api/buckets")).buckets,Zf=S=>sn(jl(S),{method:"PUT"}),Jf=S=>sn(jl(S),{method:"DELETE"}),qf=async(S,j)=>(await sn(`${jl(S)}/objects?prefix=${encodeURIComponent(j)}`)).objects,bf=(S,j,h)=>sn(ji(S,j),{method:"PUT",body:h,headers:h.type?{"Content-Type":h.type}:void 0}),ed=(S,j)=>sn(ji(S,j),{method:"DELETE"});function td(S,j){const h=new Set,q=[];for(const D of S){const A=D.key.indexOf("/",j.length);A===-1?q.push(D):h.add(D.key.slice(0,A+1))}return{folders:[...h].sort(),objects:q}}function Ua(S){if(S<1024)return`${S} B`;const j=["KiB","MiB","GiB","TiB"];let h=S,q=-1;for(;h>=1024&&q{const E=new URLSearchParams;m.bucket&&E.set("bucket",m.bucket),m.prefix&&E.set("prefix",m.prefix);const x=E.toString();window.history.pushState(null,"",x?`/?${x}`:"/"),Q(null),fe(m)},[]);ce.useEffect(()=>{const m=()=>{Q(null),fe(Aa())};return window.addEventListener("popstate",m),()=>window.removeEventListener("popstate",m)},[]);const we=ce.useCallback(async()=>{q(await Gf())},[]),Ye=ce.useCallback(async()=>{if(De.current.bucket!==K||De.current.prefix!==Z)return;const m=++ge.current;try{const E=K?await qf(K,Z):[];m===ge.current&&A(E)}catch(E){if(m===ge.current)throw E}},[K,Z]);ce.useEffect(()=>{Xf().then(j,m=>he(Rl(m))),we().catch(m=>he(Rl(m)))},[we]),ce.useEffect(()=>{he(""),A([]),Ye().catch(m=>{A([]),he(Rl(m))})},[Ye]);const Be=ce.useCallback(async(m,E)=>{et(!0),he(""),J("");try{await E(),J(m)}catch(x){he(Rl(x))}finally{et(!1),Q(null)}},[]),kt=m=>{m.preventDefault();const E=W.trim();E&&Be(`Created bucket ${E}`,async()=>{await Zf(E),xe(""),await we(),de({bucket:E,prefix:""})})},tt=()=>Be(`Deleted bucket ${K}`,async()=>{await Jf(K),de({bucket:"",prefix:""}),await we()}),Me=m=>Be(`Deleted ${m}`,async()=>{await ed(K,m),await Promise.all([Ye(),we()])}),Xe=m=>{const E=[...m],x=E.find(c=>c.size>Fa);if(x){he(`${x.name} exceeds the ${Ua(Fa)} request limit.`);return}Be(`Uploaded ${E.length} file(s)`,async()=>{for(const c of E)await bf(K,Z+c.name,c);await Promise.all([Ye(),we()])})},Ie=ce.useMemo(()=>td(D,Z),[D,Z]),ze=ce.useMemo(()=>{const m=[];let E=0;for(let x=Z.indexOf("/");x!==-1;x=Z.indexOf("/",E))m.push({label:Z.slice(E,x)||"(empty)",prefix:Z.slice(0,x+1)}),E=x+1;return m},[Z]),re=Ie.folders.length===0&&Ie.objects.length===0;return _.jsxs("div",{className:"app",children:[_.jsxs("header",{className:"topbar",children:[_.jsx("h1",{children:"devcloud S3 console"}),S&&_.jsxs("span",{className:"meta",children:[S.region," ยท ",S.authMode," auth"]})]}),_.jsxs("div",{className:"layout",children:[_.jsxs("nav",{className:"sidebar","aria-label":"Buckets",children:[_.jsxs("form",{className:"new-bucket",onSubmit:kt,children:[_.jsx("label",{htmlFor:"new-bucket",children:"New bucket"}),_.jsxs("div",{className:"row",children:[_.jsx("input",{id:"new-bucket",value:W,onChange:m=>xe(m.target.value),placeholder:"my-bucket",autoComplete:"off",spellCheck:!1}),_.jsx("button",{className:"button primary",disabled:U||!W.trim(),children:"Create"})]})]}),_.jsx("ul",{className:"bucket-list",children:h.map(m=>_.jsx("li",{children:_.jsxs("button",{className:m.name===K?"bucket active":"bucket","aria-current":m.name===K?"true":void 0,onClick:()=>de({bucket:m.name,prefix:""}),children:[_.jsx("span",{className:"bucket-name",children:m.name}),_.jsx("span",{className:"meta",children:m.objectCount})]})},m.name))}),h.length===0&&_.jsx("p",{className:"meta empty-note",children:"No buckets yet."})]}),_.jsxs("main",{className:"content",children:[_.jsxs("div",{"aria-live":"polite",children:[G&&_.jsx("p",{className:"banner error",role:"alert",children:G}),!G&&Oe&&_.jsx("p",{className:"banner",children:Oe})]}),!K&&_.jsx("p",{className:"placeholder",children:"Select or create a bucket."}),K&&_.jsxs(_.Fragment,{children:[_.jsxs("div",{className:"toolbar",children:[_.jsxs("nav",{className:"crumbs","aria-label":"Path",children:[_.jsx("button",{className:"link",onClick:()=>de({bucket:K,prefix:""}),children:K}),ze.map(m=>_.jsxs("span",{children:[_.jsx("span",{className:"separator",children:"/"}),_.jsx("button",{className:"link",onClick:()=>de({bucket:K,prefix:m.prefix}),children:m.label})]},m.prefix))]}),_.jsxs("div",{className:"row",children:[_.jsx("input",{ref:Ke,type:"file",multiple:!0,hidden:!0,"aria-label":"Files to upload",onChange:m=>{var E;(E=m.target.files)!=null&&E.length&&Xe(m.target.files),m.target.value=""}}),_.jsx("button",{className:"button primary",disabled:U,onClick:()=>{var m;return(m=Ke.current)==null?void 0:m.click()},children:"Upload"}),ye===K?_.jsxs(_.Fragment,{children:[_.jsx("button",{className:"button danger primary",disabled:U,onClick:()=>void tt(),children:"Confirm delete bucket"}),_.jsx("button",{className:"button",onClick:()=>Q(null),children:"Cancel"})]}):_.jsx("button",{className:"button danger",disabled:U,onClick:()=>Q(K),children:"Delete bucket"})]})]}),re?_.jsx("p",{className:"placeholder",children:"This location is empty."}):_.jsxs("table",{className:"objects",children:[_.jsx("thead",{children:_.jsxs("tr",{children:[_.jsx("th",{scope:"col",children:"Name"}),_.jsx("th",{scope:"col",className:"numeric",children:"Size"}),_.jsx("th",{scope:"col",children:"Type"}),_.jsx("th",{scope:"col",children:"Last modified"}),_.jsx("th",{scope:"col",children:_.jsx("span",{className:"sr-only",children:"Actions"})})]})}),_.jsxs("tbody",{children:[Ie.folders.map(m=>_.jsx("tr",{children:_.jsx("td",{colSpan:5,children:_.jsxs("button",{className:"link",onClick:()=>de({bucket:K,prefix:m}),children:[Ba(m),"/"]})})},m)),Ie.objects.map(m=>_.jsxs("tr",{children:[_.jsx("td",{className:"name",children:Ba(m.key)}),_.jsx("td",{className:"numeric",children:Ua(m.size)}),_.jsx("td",{className:"meta",children:m.contentType}),_.jsx("td",{className:"meta",children:nd(m.lastModified)}),_.jsx("td",{className:"actions",children:ye===m.key?_.jsxs(_.Fragment,{children:[_.jsx("button",{className:"button danger primary",disabled:U,onClick:()=>void Me(m.key),children:"Confirm"}),_.jsx("button",{className:"button",onClick:()=>Q(null),children:"Cancel"})]}):_.jsxs(_.Fragment,{children:[_.jsx("a",{className:"button",href:ji(K,m.key),download:!0,children:"Download"}),_.jsx("button",{className:"button danger",disabled:U,onClick:()=>Q(m.key),children:"Delete"})]})})]},m.key))]})]})]})]})]})]})}const $a=document.getElementById("root");if(!$a)throw new Error("Console root element is missing");Yf.createRoot($a).render(_.jsx(ce.StrictMode,{children:_.jsx(rd,{})})); diff --git a/services/s3/assets/console/index.html b/services/s3/assets/console/index.html index ff721f0..74baaf8 100644 --- a/services/s3/assets/console/index.html +++ b/services/s3/assets/console/index.html @@ -4,7 +4,7 @@ devcloud S3 console - + diff --git a/web/s3-console/e2e/usecases.ts b/web/s3-console/e2e/usecases.ts index 2050915..db03b04 100644 --- a/web/s3-console/e2e/usecases.ts +++ b/web/s3-console/e2e/usecases.ts @@ -382,11 +382,20 @@ export async function consoleManagesObjects(recorder: Recorder, page: Page): Pro await page.getByRole('button', { name: 'Delete', exact: true }).click() await page.getByRole('button', { name: 'Confirm', exact: true }).click() await expect(page.getByText('This location is empty.')).toBeVisible() - await recorder.step({ system: 'console', screen: 'dots', title: 'Key with dot segments deleted', checkpoint: 'CP-004', action: { kind: 'click', label: 'Switch bucket' } }, true) + await recorder.step({ system: 'console', screen: 'dots', title: 'Key with dot segments deleted', checkpoint: 'CP-004', action: { kind: 'click', label: 'Follow a breadcrumb' } }, true) + const dottedDeleted = (await raw(s3Port, 'GET', `/${dots}/x/../y`)).status === 404 + const otherKept = (await raw(s3Port, 'GET', `/${dots}/y`)).body.toString() === 'plain' + + // An empty path segment is part of the location a breadcrumb leads to. + await raw(s3Port, 'PUT', `/${dots}/a//b/c/z`, {}, 'nested') + await page.goto(`${consoleUrl}?bucket=${dots}&prefix=${encodeURIComponent('a//b/c/')}`) + await page.getByRole('navigation', { name: 'Path' }).getByRole('button', { name: 'b', exact: true }).click() + const breadcrumbPrefix = new URL(page.url()).searchParams.get('prefix') await recorder.ui('CP-004', { - dotted_deleted: (await raw(s3Port, 'GET', `/${dots}/x/../y`)).status === 404, - other_kept: (await raw(s3Port, 'GET', `/${dots}/y`)).body.toString() === 'plain', - }, { dotted_deleted: true, other_kept: true }) + dotted_deleted: dottedDeleted, + other_kept: otherKept, + breadcrumb_prefix: breadcrumbPrefix, + }, { dotted_deleted: true, other_kept: true, breadcrumb_prefix: 'a//b/' }) // CP-005: a late listing from the previous bucket const first = recorder.id('aaa') diff --git a/web/s3-console/src/App.tsx b/web/s3-console/src/App.tsx index ae8d084..f24664d 100644 --- a/web/s3-console/src/App.tsx +++ b/web/s3-console/src/App.tsx @@ -145,8 +145,14 @@ export function App() { const listing = useMemo(() => groupByFolder(objects, prefix), [objects, prefix]) const crumbs = useMemo(() => { - const parts = prefix.split('/').filter(Boolean) - return parts.map((part, index) => ({ label: part, prefix: `${parts.slice(0, index + 1).join('/')}/` })) + // Cut from the original prefix: `a//b/` and `a/b/` are different locations. + const result: { label: string; prefix: string }[] = [] + let start = 0 + for (let slash = prefix.indexOf('/'); slash !== -1; slash = prefix.indexOf('/', start)) { + result.push({ label: prefix.slice(start, slash) || '(empty)', prefix: prefix.slice(0, slash + 1) }) + start = slash + 1 + } + return result }, [prefix]) const isEmpty = listing.folders.length === 0 && listing.objects.length === 0