diff --git a/k8s/minio.yml b/k8s/minio.yml new file mode 100644 index 0000000..0eba9c4 --- /dev/null +++ b/k8s/minio.yml @@ -0,0 +1,138 @@ +# MinIO — S3-compatible object storage for the encrypted relay. +# +# Phase 2 context: the server never sees plaintext. Clients upload an +# XChaCha20-Poly1305 ciphertext directly to MinIO via a presigned PUT URL, +# and recipients download via a presigned GET URL. The symmetric key stays +# in the browser URL fragment (#k=...); the server only knows the storage +# key and blob size. +# +# Single-node deployment in-cluster. Bucket "transfers" is created on boot +# via a one-shot initContainer (mc mb --ignore-existing). +# +# DEPLOY-TIME REQUIREMENT: the API port (9000) must be publicly reachable at +# the host declared by S3_ENDPOINT in server.yml (s3.anydrop.arthurbarre.fr). +# Presigned URLs are signed against that host, and the browser must be able +# to resolve it. Configure an external route (traefik ingress, nginx, etc.) +# from that hostname to this Service on port 9000. + +apiVersion: v1 +kind: Service +metadata: + name: minio + namespace: anydrop + labels: + app: minio +spec: + clusterIP: None + selector: + app: minio + ports: + - name: api + port: 9000 + targetPort: 9000 + - name: console + port: 9001 + targetPort: 9001 + +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: minio + namespace: anydrop +spec: + serviceName: minio + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + initContainers: + - name: ensure-bucket + image: minio/mc:latest + command: + - /bin/sh + - -c + - | + set -e + until mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" 2>/dev/null; do + echo "waiting for minio..." + sleep 2 + done + mc mb --ignore-existing local/transfers + # Keep the bucket private — every object is served via presigned URL. + mc anonymous set none local/transfers + echo "bucket ready" + env: + - name: MINIO_ROOT_USER + valueFrom: + secretKeyRef: + name: minio-credentials + key: access_key + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: minio-credentials + key: secret_key + containers: + - name: minio + image: minio/minio:latest + args: + - server + - /data + - --console-address + - ":9001" + ports: + - containerPort: 9000 + name: api + - containerPort: 9001 + name: console + env: + - name: MINIO_ROOT_USER + valueFrom: + secretKeyRef: + name: minio-credentials + key: access_key + - name: MINIO_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: minio-credentials + key: secret_key + - name: MINIO_BROWSER_REDIRECT_URL + value: "https://anydrop.arthurbarre.fr/minio-console" + volumeMounts: + - name: data + mountPath: /data + livenessProbe: + httpGet: + path: /minio/health/live + port: 9000 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /minio/health/ready + port: 9000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "1000m" + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi diff --git a/k8s/secrets.example.yml b/k8s/secrets.example.yml index a9b5151..1425614 100644 --- a/k8s/secrets.example.yml +++ b/k8s/secrets.example.yml @@ -16,6 +16,13 @@ # --from-literal=SESSION_SECRET="$SESSION_SECRET" \ # --from-literal=DATABASE_URL="$DATABASE_URL" # +# # MinIO (object storage for the encrypted relay) +# MINIO_ACCESS_KEY=$(openssl rand -hex 16) +# MINIO_SECRET_KEY=$(openssl rand -base64 40 | tr -d '=+/') +# kubectl -n anydrop create secret generic minio-credentials \ +# --from-literal=access_key="$MINIO_ACCESS_KEY" \ +# --from-literal=secret_key="$MINIO_SECRET_KEY" +# # Rotate by replacing the secret and restarting the pods: # kubectl -n anydrop rollout restart deployment/anydrop-server # --------------------------------------------------------------------------- @@ -39,3 +46,14 @@ type: Opaque stringData: SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING DATABASE_URL: postgres://anydrop:CHANGE_ME@postgres.anydrop.svc.cluster.local:5432/anydrop + +--- +apiVersion: v1 +kind: Secret +metadata: + name: minio-credentials + namespace: anydrop +type: Opaque +stringData: + access_key: CHANGE_ME_ACCESS_KEY + secret_key: CHANGE_ME_SECRET_KEY diff --git a/k8s/server.yml b/k8s/server.yml index b924c80..7bfb4ab 100644 --- a/k8s/server.yml +++ b/k8s/server.yml @@ -15,6 +15,14 @@ data: SMTP_SECURE: "false" SMTP_TLS_REJECT_UNAUTHORIZED: "false" SMTP_FROM: "AnyDrop " + # Phase 2 — encrypted cloud relay (MinIO in-cluster) + # Endpoint must be publicly reachable: the browser uses presigned URLs + # signed against this host, so the hostname seen by the server and the + # client must match. Ingress routes s3.anydrop.arthurbarre.fr → minio:9000. + S3_ENDPOINT: "https://s3.anydrop.arthurbarre.fr" + S3_REGION: "us-east-1" + S3_BUCKET: "transfers" + S3_FORCE_PATH_STYLE: "true" --- apiVersion: apps/v1 @@ -51,6 +59,17 @@ spec: name: anydrop-server-config - secretRef: name: anydrop-app-secrets + env: + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: minio-credentials + key: access_key + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: minio-credentials + key: secret_key livenessProbe: httpGet: path: /health diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3f32cf..fc83582 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@anydrop/shared': specifier: workspace:* version: link:../shared + '@aws-sdk/client-s3': + specifier: ^3.1032.0 + version: 3.1032.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1032.0 + version: 3.1032.0 '@hono/node-server': specifier: ^1.13.7 version: 1.19.14(hono@4.12.14) @@ -71,6 +77,12 @@ importers: '@anydrop/shared': specifier: workspace:* version: link:../shared + '@noble/ciphers': + specifier: ^2.2.0 + version: 2.2.0 + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 events: specifier: ^3.3.0 version: 3.3.0 @@ -151,6 +163,173 @@ packages: peerDependencies: ajv: '>=8' + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.1032.0': + resolution: {integrity: sha512-A1wjVhV3IgsZ5td2l4AWgK03EjZ+ldwbiorxuO1hPf7RHJtSdr6oq/gKzyUwP7Tm7ma/M2xS/tplg5C8XB8RWg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.1': + resolution: {integrity: sha512-gy/gffKz0zaHDaqRiLCdIvgHmaAL/HXuAtMcBP7euYSFx4BsbsdlfmUBJag+Gqe62z6/XuloKyQyaiH+kS3Vrg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.7': + resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.27': + resolution: {integrity: sha512-xfUt2CUZDC+Tf16A6roD1b4pk/nrXdkoLY3TEhv198AXDtBo5xUJP1zd0e8SmuKLN4PpIBX96OizZbmMlcI6oQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.29': + resolution: {integrity: sha512-hjNeYb6oLyHgMihra83ie0J/T2y9om3cy1qC90h9DRgvYXEoN4BCFf8bHguZjKhXunnv7YkmZRuYL5Mkk77eCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.31': + resolution: {integrity: sha512-PuQ7e8WYzAPpzvFcajxf8c0LqSzakVHVlKw8M0oubk8Kf347YOCCqT1seQrHs5AdZuIh2RD9LX4O+Xa5ImEBfQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.31': + resolution: {integrity: sha512-bBmWDmtSpmLOZR6a0kmowBcVL1hiL8Vlap/RXeMpFd7JbWl87YcwqL6T9LH/0oBVEZXu1dUZAtojgSuZgMO5xw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.32': + resolution: {integrity: sha512-9aj0x9hGYUondBZSD0XkksAdHhOKttFw4BWpLCeggeg40qSJxGrAP++g0GCm0VqWc1WtC/NRFiAVzPCy56vmog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.27': + resolution: {integrity: sha512-1CZvfb1WzudWWIFAVQkd1OI/T1RxPcSvNWzNsb2BMBVsBJzBtB8dV5f2nymHVU4UqwxipdVt/DAbgdDRf33JDg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.31': + resolution: {integrity: sha512-x8Mx18S48XMl9bEEpYwmXDTvjWGPIfDadReN37Lc099/DUrlL4Zs9T9rwwggo6DkKS1aev6v+MTUx7JTa87TZQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.31': + resolution: {integrity: sha512-zfuNMIkGfjYsHis9qytYf74Bcmq6Ji9Xwf4w53baRCI/b2otTwZv3SW1uRiJ5Di7999QzRGhHZ96+eUeo3gSOA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.10': + resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.10': + resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.9': + resolution: {integrity: sha512-ye6xVuMEQ5NCT+yQOryGYsuCXnOwu7iGFGzV+qpXZOWtqXIAAaFostapxj6RCubw36rekVwmdB2lcspFuyNfYQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.10': + resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.10': + resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.11': + resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.30': + resolution: {integrity: sha512-hoQRxjJu4tt3gEOQin21rJKotClJC+x7AmCh9ylRct1DJeaNI/BRlFxMbuhJe54bG6xANPagSs0my8K30QyV9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.10': + resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.31': + resolution: {integrity: sha512-L+hXN2HDomlIsWSHW5DVD7ppccCeRnlHXZ5uHG34ePTjF5bm0I1fmrJLbUGiW97xRXWryit5cjdP4Sx2FwiGog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.996.21': + resolution: {integrity: sha512-Me3d/ua2lb2G0bQfFmvCeQQp3+nN6GSPqMxDmi/IQlQ8CrlpQ5C0JJHpz2AnOUkEFI0lBNrAL3Vnt29l44ndkA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.12': + resolution: {integrity: sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1032.0': + resolution: {integrity: sha512-LFaI5JQhiOmJDjKK02ir9oERU9AmxdyEvzv332oPDzAzWeNH06sZ1WsF3xRBBE5tbEH2jIc79N8EqDCY0s5kKQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.18': + resolution: {integrity: sha512-4KT8UXRmvNAP5zKq9UI1MIwbnmSChZncBt89RKu/skMqZSSWGkBZTAJsZ+no+txfmF3kVaUFv31CTBZkQ5BJpQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1032.0': + resolution: {integrity: sha512-n+PU8Z+gll7p3wDrH+Wo6fkt8sPrVnq30YYM6Ryga95oJlEneNMEbDHj0iqjMX3V7gaGdJo/hJWyPo4lscP+mA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.8': + resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.7': + resolution: {integrity: sha512-ty4LQxN1QC+YhUP28NfEgZDEGXkyqOQy+BDriBozqHsrYO4JMgiPhfizqOGF7P+euBTZ5Ez6SKlLAMCLo8tzmw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.10': + resolution: {integrity: sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.10': + resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} + + '@aws-sdk/util-user-agent-node@3.973.17': + resolution: {integrity: sha512-utF5qjjbuJQuU9VdCkWl7L87sr93cApsrD+uxGfUnlafX8iyEzJrb7EZnufjThURZVTOtelRMXrblWxpefElUg==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.18': + resolution: {integrity: sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1142,6 +1321,14 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1353,6 +1540,218 @@ packages: cpu: [x64] os: [win32] + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.16': + resolution: {integrity: sha512-GFlGPNLZKrGfqWpqVb31z7hvYCA9ZscfX1buYnvvMGcRYsQQnhH+4uN6mWWflcD5jB4OXP/LBrdpukEdjl41tg==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.15': + resolution: {integrity: sha512-E7GVCgsQttzfujEZb6Qep005wWf4xiL4x06apFEtzQMWYBPggZh/0cnOxPficw5cuK/YjjkehKoIN4YUaSh0UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.14': + resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.14': + resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.14': + resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.14': + resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.14': + resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.14': + resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.17': + resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.15': + resolution: {integrity: sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.14': + resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.14': + resolution: {integrity: sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.14': + resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.14': + resolution: {integrity: sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.14': + resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.30': + resolution: {integrity: sha512-qS2XqhKeXmdZ4nEQ4cOxIczSP/Y91wPAHYuRwmWDCh975B7/57uxsm5d6sisnUThn2u2FwzMdJNM7AbO1YPsPg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.3': + resolution: {integrity: sha512-TE8dJNi6JuxzGSxMCVd3i9IEWDndCl3bmluLsBNDWok8olgj65OfkndMhl9SZ7m14c+C5SQn/PcUmrDl57rSFw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.18': + resolution: {integrity: sha512-M6CSgnp3v4tYz9ynj2JHbA60woBZcGqEwNjTKjBsNHPV26R1ZX52+0wW8WsZU18q45jD0tw2wL22S17Ze9LpEw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.14': + resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.14': + resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.5.3': + resolution: {integrity: sha512-lc5jFL++x17sPhIwMWJ3YOnqmSjw/2Po6VLDlUIXvxVWRuJwRXnJ4jOBBLB0cfI5BB5ehIl02Fxr1PDvk/kxDw==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.14': + resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.14': + resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.14': + resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.14': + resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.14': + resolution: {integrity: sha512-vVimoUnGxlx4eLLQbZImdOZFOe+Zh+5ACntv8VxZuGP72LdWu5GV3oEmCahSEReBgRJoWjypFkrehSj7BWx1HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.9': + resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.14': + resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.11': + resolution: {integrity: sha512-wzz/Wa1CH/Tlhxh0s4DQPEcXSxSVfJ59AZcUh9Gu0c6JTlKuwGf4o/3P2TExv0VbtPFt8odIBG+eQGK2+vTECg==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.1': + resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.14': + resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.47': + resolution: {integrity: sha512-zlIuXai3/SHjQUQ8y3g/woLvrH573SK2wNjcDaHu5e9VOcC0JwM1MI0Sq0GZJyN3BwSUneIhpjZ18nsiz5AtQw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.52': + resolution: {integrity: sha512-cQBz8g68Vnw1W2meXlkb3D/hXJU+Taiyj9P8qLJtjREEV9/Td65xi4A/H1sRQ8EIgX5qbZbvdYPKygKLholZ3w==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.4.1': + resolution: {integrity: sha512-wMxNDZJrgS5mQV9oxCs4TWl5767VMgOfqfZ3JHyCkMtGC2ykW9iPqMvFur695Otcc5yxLG8OKO/80tsQBxrhXg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.14': + resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.2': + resolution: {integrity: sha512-2+KTsJEwTi63NUv4uR9IQ+IFT1yu6Rf6JuoBK2WKaaJ/TRvOiOVGcXAsEqX/TQN2thR9yII21kPUJq1UV/WI2A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.23': + resolution: {integrity: sha512-N6on1+ngJ3RznZOnDWNveIwnTSlqxNnXuNAh7ez889ZZaRdXoNRTXKgmYOLe6dB0gCmAVtuRScE1hymQFl4hpg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.16': + resolution: {integrity: sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} @@ -1521,6 +1920,9 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} @@ -1932,6 +2334,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.1.5: + resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2466,6 +2875,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2877,6 +3290,9 @@ packages: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -3240,6 +3656,468 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1032.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.1 + '@aws-sdk/credential-provider-node': 3.972.32 + '@aws-sdk/middleware-bucket-endpoint': 3.972.10 + '@aws-sdk/middleware-expect-continue': 3.972.10 + '@aws-sdk/middleware-flexible-checksums': 3.974.9 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-sdk-s3': 3.972.30 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.31 + '@aws-sdk/region-config-resolver': 3.972.12 + '@aws-sdk/signature-v4-multi-region': 3.996.18 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.7 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.17 + '@smithy/config-resolver': 4.4.16 + '@smithy/core': 3.23.15 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/eventstream-serde-config-resolver': 4.3.14 + '@smithy/eventstream-serde-node': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-blob-browser': 4.2.15 + '@smithy/hash-node': 4.2.14 + '@smithy/hash-stream-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/md5-js': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.30 + '@smithy/middleware-retry': 4.5.3 + '@smithy/middleware-serde': 4.2.18 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.5.3 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.47 + '@smithy/util-defaults-mode-node': 4.2.52 + '@smithy/util-endpoints': 3.4.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.2 + '@smithy/util-stream': 4.5.23 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.16 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.974.1': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.18 + '@smithy/core': 3.23.15 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.7': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.27': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.29': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/types': 3.973.8 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.5.3 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.23 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/credential-provider-env': 3.972.27 + '@aws-sdk/credential-provider-http': 3.972.29 + '@aws-sdk/credential-provider-login': 3.972.31 + '@aws-sdk/credential-provider-process': 3.972.27 + '@aws-sdk/credential-provider-sso': 3.972.31 + '@aws-sdk/credential-provider-web-identity': 3.972.31 + '@aws-sdk/nested-clients': 3.996.21 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/nested-clients': 3.996.21 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.32': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.27 + '@aws-sdk/credential-provider-http': 3.972.29 + '@aws-sdk/credential-provider-ini': 3.972.31 + '@aws-sdk/credential-provider-process': 3.972.27 + '@aws-sdk/credential-provider-sso': 3.972.31 + '@aws-sdk/credential-provider-web-identity': 3.972.31 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.27': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/nested-clients': 3.996.21 + '@aws-sdk/token-providers': 3.1032.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/nested-clients': 3.996.21 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.9': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.1 + '@aws-sdk/crc64-nvme': 3.972.7 + '@aws-sdk/types': 3.973.8 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.23 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.30': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.15 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.23 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.7 + '@smithy/core': 3.23.15 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-retry': 4.3.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.21': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.1 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.31 + '@aws-sdk/region-config-resolver': 3.972.12 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.7 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.17 + '@smithy/config-resolver': 4.4.16 + '@smithy/core': 3.23.15 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.30 + '@smithy/middleware-retry': 4.5.3 + '@smithy/middleware-serde': 4.2.18 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.5.3 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.47 + '@smithy/util-defaults-mode-node': 4.2.52 + '@smithy/util-endpoints': 3.4.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/config-resolver': 4.4.16 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1032.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.996.18 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-format-url': 3.972.10 + '@smithy/middleware-endpoint': 4.4.30 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.18': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.30 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1032.0': + dependencies: + '@aws-sdk/core': 3.974.1 + '@aws-sdk/nested-clients': 3.996.21 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.7': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.1 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.17': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.31 + '@aws-sdk/types': 3.973.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.18': + dependencies: + '@smithy/types': 4.14.1 + fast-xml-parser: 5.5.8 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4168,6 +5046,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/ciphers@2.2.0': {} + + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4315,6 +5197,339 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@smithy/chunked-blob-reader-native@4.2.3': + dependencies: + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.16': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.4.1 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/core@3.23.15': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.23 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.14': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.14': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.14': + dependencies: + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.15': + dependencies: + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.14': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.30': + dependencies: + '@smithy/core': 3.23.15 + '@smithy/middleware-serde': 4.2.18 + '@smithy/node-config-provider': 4.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.3': + dependencies: + '@smithy/core': 3.23.15 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/service-error-classification': 4.2.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.18': + dependencies: + '@smithy/core': 3.23.15 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.14': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.5.3': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + + '@smithy/shared-ini-file-loader@4.4.9': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.14': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.11': + dependencies: + '@smithy/core': 3.23.15 + '@smithy/middleware-endpoint': 4.4.30 + '@smithy/middleware-stack': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.23 + tslib: 2.8.1 + + '@smithy/types@4.14.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.14': + dependencies: + '@smithy/querystring-parser': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.47': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.52': + dependencies: + '@smithy/config-resolver': 4.4.16 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.11 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.4.1': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.3.2': + dependencies: + '@smithy/service-error-classification': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.23': + dependencies: + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.5.3 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.16': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: ejs: 3.1.10 @@ -4513,6 +5728,8 @@ snapshots: bn.js@5.2.3: {} + bowser@2.14.1: {} + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 @@ -5021,6 +6238,16 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.1.5: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.5.8: + dependencies: + fast-xml-builder: 1.1.5 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5561,6 +6788,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -6047,6 +7276,8 @@ snapshots: strip-comments@2.0.1: {} + strnum@2.2.3: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 diff --git a/server/package.json b/server/package.json index b20fcf8..415d05b 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "@anydrop/shared": "workspace:*", + "@aws-sdk/client-s3": "^3.1032.0", + "@aws-sdk/s3-request-presigner": "^3.1032.0", "@hono/node-server": "^1.13.7", "drizzle-orm": "^0.45.2", "hono": "^4.6.14", diff --git a/server/src/db/migrations/0001_loving_yellowjacket.sql b/server/src/db/migrations/0001_loving_yellowjacket.sql new file mode 100644 index 0000000..cb5ca71 --- /dev/null +++ b/server/src/db/migrations/0001_loving_yellowjacket.sql @@ -0,0 +1,22 @@ +CREATE TABLE "transfers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "storage_key" text NOT NULL, + "sender_user_id" uuid, + "sender_device_id" text, + "recipient_user_id" uuid, + "recipient_email_hash" text, + "encrypted_metadata" text NOT NULL, + "size_bytes" bigint NOT NULL, + "max_downloads" integer DEFAULT 1 NOT NULL, + "download_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "first_download_at" timestamp with time zone, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "transfers" ADD CONSTRAINT "transfers_sender_user_id_users_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transfers" ADD CONSTRAINT "transfers_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "transfers_sender_idx" ON "transfers" USING btree ("sender_user_id");--> statement-breakpoint +CREATE INDEX "transfers_recipient_idx" ON "transfers" USING btree ("recipient_user_id");--> statement-breakpoint +CREATE INDEX "transfers_expires_idx" ON "transfers" USING btree ("expires_at"); \ No newline at end of file diff --git a/server/src/db/migrations/meta/0001_snapshot.json b/server/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..7b31a06 --- /dev/null +++ b/server/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,553 @@ +{ + "id": "50199a15-ea37-4c61-beee-71f2d99cd292", + "prevId": "a3d4d541-ef82-42cb-b317-1b27aca7bff6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.magic_links": { + "name": "magic_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "magic_links_token_hash_unique": { + "name": "magic_links_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "magic_links_email_idx": { + "name": "magic_links_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transfers": { + "name": "transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_device_id": { + "name": "sender_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_email_hash": { + "name": "recipient_email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_metadata": { + "name": "encrypted_metadata", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "max_downloads": { + "name": "max_downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "first_download_at": { + "name": "first_download_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "transfers_sender_idx": { + "name": "transfers_sender_idx", + "columns": [ + { + "expression": "sender_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transfers_recipient_idx": { + "name": "transfers_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transfers_expires_idx": { + "name": "transfers_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transfers_sender_user_id_users_id_fk": { + "name": "transfers_sender_user_id_users_id_fk", + "tableFrom": "transfers", + "tableTo": "users", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transfers_recipient_user_id_users_id_fk": { + "name": "transfers_recipient_user_id_users_id_fk", + "tableFrom": "transfers", + "tableTo": "users", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_devices": { + "name": "user_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_devices_user_device_unique": { + "name": "user_devices_user_device_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_devices_user_id_users_id_fk": { + "name": "user_devices_user_id_users_id_fk", + "tableFrom": "user_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 69c996d..c4fcd35 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776644472089, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1776675064100, + "tag": "0001_loving_yellowjacket", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index ff96063..0503509 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, uuid, uniqueIndex, index } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, uuid, uniqueIndex, index, bigint, integer } from "drizzle-orm/pg-core"; export const users = pgTable( "users", @@ -70,8 +70,52 @@ export const userDevices = pgTable( }), ); +/** + * Encrypted cloud relay transfers (Phase 2). + * + * The server is intentionally blind to the content: + * - object key in MinIO holds the ciphertext only + * - encryptedMetadata holds filename/mime/size (AEAD sealed with the same key) + * - the symmetric key NEVER reaches the server; it lives in the URL fragment + * (#k=...) and is only ever handled by sender and recipient clients + * + * Columns the server legitimately needs: + * - id + storage key (routing) + * - senderUserId (so the sender's /inbox can list their sends) + * - recipientUserId (nullable — set when sending to a known user, lets /inbox + * surface incoming cloud relays) + * - sizeBytes (enforce plan quotas; ciphertext size, no content leak) + * - maxDownloads / downloadCount / expiresAt / consumedAt (lifecycle) + */ +export const transfers = pgTable( + "transfers", + { + id: uuid("id").primaryKey().defaultRandom(), + storageKey: text("storage_key").notNull(), + senderUserId: uuid("sender_user_id").references(() => users.id, { onDelete: "set null" }), + senderDeviceId: text("sender_device_id"), + recipientUserId: uuid("recipient_user_id").references(() => users.id, { onDelete: "set null" }), + recipientEmailHash: text("recipient_email_hash"), + encryptedMetadata: text("encrypted_metadata").notNull(), + sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(), + maxDownloads: integer("max_downloads").notNull().default(1), + downloadCount: integer("download_count").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + firstDownloadAt: timestamp("first_download_at", { withTimezone: true }), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + }, + (t) => ({ + senderIdx: index("transfers_sender_idx").on(t.senderUserId), + recipientIdx: index("transfers_recipient_idx").on(t.recipientUserId), + expiresIdx: index("transfers_expires_idx").on(t.expiresAt), + }), +); + export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Session = typeof sessions.$inferSelect; export type MagicLink = typeof magicLinks.$inferSelect; export type UserDevice = typeof userDevices.$inferSelect; +export type Transfer = typeof transfers.$inferSelect; +export type NewTransfer = typeof transfers.$inferInsert; diff --git a/server/src/http/app.ts b/server/src/http/app.ts index 256f182..35537dd 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { authRoutes } from "./auth.js"; import { meRoutes } from "./me.js"; +import { transferRoutes } from "./transfers.js"; export function buildApp() { const app = new Hono(); @@ -12,7 +13,7 @@ export function buildApp() { cors({ origin: corsOrigin, credentials: true, - allowHeaders: ["Content-Type"], + allowHeaders: ["Content-Type", "X-Device-Id"], allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], }), ); @@ -21,6 +22,7 @@ export function buildApp() { app.route("/api/auth", authRoutes); app.route("/api", meRoutes); + app.route("/api", transferRoutes); app.notFound((c) => c.json({ error: "not_found" }, 404)); app.onError((err, c) => { diff --git a/server/src/http/transfers.ts b/server/src/http/transfers.ts new file mode 100644 index 0000000..a1b12b0 --- /dev/null +++ b/server/src/http/transfers.ts @@ -0,0 +1,244 @@ +import { createHash, randomUUID } from "node:crypto"; +import { Hono } from "hono"; +import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { transfers, users } from "../db/schema.js"; +import { resolveSession } from "./session.js"; +import { rateLimit } from "./middleware.js"; +import { deleteObject, presignDownload, presignUpload } from "../storage/s3.js"; + +export const transferRoutes = new Hono(); + +const UPLOAD_TTL_SECONDS = 15 * 60; +const DOWNLOAD_TTL_SECONDS = 10 * 60; +const DEFAULT_EXPIRY_DAYS = 7; +const MAX_EXPIRY_DAYS = 30; +const MAX_METADATA_LEN = 8_192; +const MAX_SIZE_BYTES_FREE = 2 * 1024 * 1024 * 1024; +const MAX_MAX_DOWNLOADS = 100; + +transferRoutes.use("/transfers", rateLimit(30)); +transferRoutes.use("/transfers/*", rateLimit(60)); + +function storageKey(id: string): string { + return `t/${id.slice(0, 2)}/${id}`; +} + +function hashEmail(email: string): string { + return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); +} + +function isValidUuid(v: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); +} + +/** + * POST /api/transfers + * Create a transfer. Body: + * { + * sizeBytes: number, // ciphertext size + * encryptedMetadata: string, // base64 AEAD-sealed JSON {name, mime, size} + * recipientEmail?: string, // hashed before persist; used for /inbox routing + * maxDownloads?: number, // default 1 + * expiresInDays?: number // default 7, cap 30 + * } + * Returns: { transferId, uploadUrl, storageKey, expiresAt } + */ +transferRoutes.post("/transfers", async (c) => { + const user = await resolveSession(c); + const senderDeviceId = c.req.header("x-device-id") ?? null; + + let body: any; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "invalid_body" }, 400); + } + + const sizeBytes = Number(body.sizeBytes); + if (!Number.isInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > MAX_SIZE_BYTES_FREE) { + return c.json({ error: "invalid_size" }, 400); + } + + const encryptedMetadata = typeof body.encryptedMetadata === "string" ? body.encryptedMetadata : ""; + if (!encryptedMetadata || encryptedMetadata.length > MAX_METADATA_LEN) { + return c.json({ error: "invalid_metadata" }, 400); + } + + const maxDownloads = Number.isInteger(body.maxDownloads) + ? Math.max(1, Math.min(MAX_MAX_DOWNLOADS, body.maxDownloads)) + : 1; + + const expiresInDays = Number.isInteger(body.expiresInDays) + ? Math.max(1, Math.min(MAX_EXPIRY_DAYS, body.expiresInDays)) + : DEFAULT_EXPIRY_DAYS; + const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); + + let recipientUserId: string | null = null; + let recipientEmailHash: string | null = null; + if (typeof body.recipientEmail === "string" && body.recipientEmail.trim()) { + const email = body.recipientEmail.trim().toLowerCase(); + recipientEmailHash = hashEmail(email); + const match = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email)) + .limit(1); + if (match.length > 0) recipientUserId = match[0].id; + } + + const id = randomUUID(); + const key = storageKey(id); + + const [row] = await db + .insert(transfers) + .values({ + id, + storageKey: key, + senderUserId: user?.id ?? null, + senderDeviceId, + recipientUserId, + recipientEmailHash, + encryptedMetadata, + sizeBytes, + maxDownloads, + expiresAt, + }) + .returning(); + + const uploadUrl = await presignUpload(key, sizeBytes, UPLOAD_TTL_SECONDS); + + return c.json( + { + transferId: row.id, + uploadUrl, + expiresAt: row.expiresAt, + }, + 201, + ); +}); + +/** + * GET /api/transfers/:id + * Head-style: returns metadata + a presigned download URL. Does NOT yet + * bump the download counter — that's what POST /consume is for, so the + * recipient client can poll metadata before committing. + */ +transferRoutes.get("/transfers/:id", async (c) => { + const id = c.req.param("id"); + if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404); + + const [row] = await db.select().from(transfers).where(eq(transfers.id, id)).limit(1); + if (!row || row.deletedAt) return c.json({ error: "not_found" }, 404); + if (row.expiresAt < new Date()) return c.json({ error: "expired" }, 410); + if (row.downloadCount >= row.maxDownloads) return c.json({ error: "consumed" }, 410); + + return c.json({ + transferId: row.id, + encryptedMetadata: row.encryptedMetadata, + sizeBytes: row.sizeBytes, + maxDownloads: row.maxDownloads, + downloadCount: row.downloadCount, + expiresAt: row.expiresAt, + }); +}); + +/** + * POST /api/transfers/:id/consume + * Atomically increments downloadCount and returns a presigned GET URL. + * Prevents two recipients from concurrently claiming the last slot. + */ +transferRoutes.post("/transfers/:id/consume", async (c) => { + const id = c.req.param("id"); + if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404); + + const [row] = await db + .update(transfers) + .set({ + downloadCount: sql`${transfers.downloadCount} + 1`, + firstDownloadAt: sql`coalesce(${transfers.firstDownloadAt}, now())`, + }) + .where( + and( + eq(transfers.id, id), + isNull(transfers.deletedAt), + gt(transfers.expiresAt, new Date()), + sql`${transfers.downloadCount} < ${transfers.maxDownloads}`, + ), + ) + .returning(); + + if (!row) return c.json({ error: "not_available" }, 410); + + const downloadUrl = await presignDownload(row.storageKey, DOWNLOAD_TTL_SECONDS); + return c.json({ downloadUrl, expiresInSeconds: DOWNLOAD_TTL_SECONDS }); +}); + +/** + * GET /api/transfers + * List the authenticated user's inbox (things sent TO them) and outbox + * (things they sent). Signed-in only. + */ +transferRoutes.get("/transfers", async (c) => { + const user = await resolveSession(c); + if (!user) return c.json({ error: "unauthenticated" }, 401); + + const rows = await db + .select({ + id: transfers.id, + sizeBytes: transfers.sizeBytes, + encryptedMetadata: transfers.encryptedMetadata, + createdAt: transfers.createdAt, + expiresAt: transfers.expiresAt, + maxDownloads: transfers.maxDownloads, + downloadCount: transfers.downloadCount, + firstDownloadAt: transfers.firstDownloadAt, + senderUserId: transfers.senderUserId, + recipientUserId: transfers.recipientUserId, + }) + .from(transfers) + .where( + and( + isNull(transfers.deletedAt), + or( + eq(transfers.senderUserId, user.id), + eq(transfers.recipientUserId, user.id), + ), + ), + ) + .orderBy(desc(transfers.createdAt)) + .limit(50); + + return c.json({ + transfers: rows.map((r) => ({ + ...r, + direction: r.senderUserId === user.id ? "sent" : "received", + })), + }); +}); + +/** + * DELETE /api/transfers/:id + * Sender can revoke. Marks deleted, purges the blob asynchronously. + */ +transferRoutes.delete("/transfers/:id", async (c) => { + const user = await resolveSession(c); + if (!user) return c.json({ error: "unauthenticated" }, 401); + + const id = c.req.param("id"); + if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404); + + const [row] = await db + .update(transfers) + .set({ deletedAt: new Date() }) + .where(and(eq(transfers.id, id), eq(transfers.senderUserId, user.id), isNull(transfers.deletedAt))) + .returning(); + + if (!row) return c.json({ error: "not_found" }, 404); + + deleteObject(row.storageKey).catch((err) => + console.error("[transfers] delete blob failed:", row.storageKey, err), + ); + + return c.body(null, 204); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 838f443..16811b3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,6 +20,7 @@ import { wakeDevice, } from "./push.js"; import { buildApp } from "./http/app.js"; +import { startCleanupLoop } from "./storage/cleanup.js"; const PORT = parseInt(process.env.PORT || "3001", 10); const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; @@ -494,3 +495,9 @@ function handleLeave(client: Client): void { httpServer.listen(PORT, () => { console.log(`AnyDrop signaling server running on port ${PORT}`); }); + +if (process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY) { + startCleanupLoop(); +} else { + console.log("[cleanup] S3 credentials not set, skipping transfer cleanup loop"); +} diff --git a/server/src/storage/cleanup.ts b/server/src/storage/cleanup.ts new file mode 100644 index 0000000..e2724e7 --- /dev/null +++ b/server/src/storage/cleanup.ts @@ -0,0 +1,56 @@ +import { lt, or, and, isNull, sql } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { transfers } from "../db/schema.js"; +import { deleteObject } from "./s3.js"; + +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; + +/** + * Sweep the transfers table: + * - purge MinIO blobs for transfers whose download quota is hit + * (so the bytes stop costing us storage as soon as they're useless) + * - purge MinIO blobs + rows for transfers past their expiration window + * + * Run on an interval from the server process. Idempotent — safe to run + * concurrently because we filter on `deleted_at IS NULL` and mark it set + * before issuing the S3 delete. + */ +export async function runCleanup(): Promise { + const now = new Date(); + + const expired = await db + .update(transfers) + .set({ deletedAt: now }) + .where( + and( + isNull(transfers.deletedAt), + or( + lt(transfers.expiresAt, now), + sql`${transfers.downloadCount} >= ${transfers.maxDownloads}`, + ), + ), + ) + .returning({ id: transfers.id, storageKey: transfers.storageKey }); + + if (expired.length === 0) return; + + console.log(`[cleanup] purging ${expired.length} expired/consumed transfers`); + + await Promise.all( + expired.map((t) => + deleteObject(t.storageKey).catch((err) => + console.error(`[cleanup] failed to delete ${t.storageKey}:`, err), + ), + ), + ); +} + +export function startCleanupLoop(): () => void { + runCleanup().catch((err) => console.error("[cleanup] initial run failed:", err)); + const interval = setInterval(() => { + runCleanup().catch((err) => console.error("[cleanup] interval run failed:", err)); + }, CLEANUP_INTERVAL_MS); + interval.unref(); + return () => clearInterval(interval); +} + diff --git a/server/src/storage/s3.ts b/server/src/storage/s3.ts new file mode 100644 index 0000000..5e18a9e --- /dev/null +++ b/server/src/storage/s3.ts @@ -0,0 +1,81 @@ +import { + S3Client, + DeleteObjectCommand, + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +const BUCKET = process.env.S3_BUCKET ?? "transfers"; +const ENDPOINT = process.env.S3_ENDPOINT ?? "http://minio:9000"; +const REGION = process.env.S3_REGION ?? "us-east-1"; +const ACCESS_KEY = process.env.S3_ACCESS_KEY ?? ""; +const SECRET_KEY = process.env.S3_SECRET_KEY ?? ""; +const FORCE_PATH_STYLE = (process.env.S3_FORCE_PATH_STYLE ?? "true") === "true"; + +let client: S3Client | null = null; + +export function getS3Client(): S3Client { + if (client) return client; + if (!ACCESS_KEY || !SECRET_KEY) { + throw new Error("S3_ACCESS_KEY and S3_SECRET_KEY must be set"); + } + client = new S3Client({ + endpoint: ENDPOINT, + region: REGION, + credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }, + forcePathStyle: FORCE_PATH_STYLE, + }); + return client; +} + +export function getBucket(): string { + return BUCKET; +} + +/** + * Presigned PUT URL for upload. The client PUTs the ciphertext directly + * to MinIO — the server never touches the bytes. + */ +export async function presignUpload( + storageKey: string, + sizeBytes: number, + ttlSeconds: number, +): Promise { + const cmd = new PutObjectCommand({ + Bucket: BUCKET, + Key: storageKey, + ContentLength: sizeBytes, + ContentType: "application/octet-stream", + }); + return getSignedUrl(getS3Client(), cmd, { expiresIn: ttlSeconds }); +} + +/** + * Presigned GET URL for download. Recipient fetches the ciphertext + * directly from MinIO and decrypts client-side. + */ +export async function presignDownload( + storageKey: string, + ttlSeconds: number, +): Promise { + const cmd = new GetObjectCommand({ + Bucket: BUCKET, + Key: storageKey, + }); + return getSignedUrl(getS3Client(), cmd, { expiresIn: ttlSeconds }); +} + +export async function deleteObject(storageKey: string): Promise { + await getS3Client().send(new DeleteObjectCommand({ Bucket: BUCKET, Key: storageKey })); +} + +export async function objectExists(storageKey: string): Promise { + try { + await getS3Client().send(new HeadObjectCommand({ Bucket: BUCKET, Key: storageKey })); + return true; + } catch { + return false; + } +} diff --git a/web/package.json b/web/package.json index 7afdb5c..ab0aa57 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@anydrop/shared": "workspace:*", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", "events": "^3.3.0", "process": "^0.11.10", "qrcode.react": "^4.2.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index a45bd09..bc6c7f3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,8 @@ import JoinRoom from "./pages/JoinRoom"; import Share from "./pages/Share"; import Pair from "./pages/Pair"; import Settings from "./pages/Settings"; +import Receive from "./pages/Receive"; +import Inbox from "./pages/Inbox"; export default function App() { return ( @@ -12,6 +14,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> ); diff --git a/web/src/components/CloudSharePanel.tsx b/web/src/components/CloudSharePanel.tsx new file mode 100644 index 0000000..583b8e2 --- /dev/null +++ b/web/src/components/CloudSharePanel.tsx @@ -0,0 +1,273 @@ +import { useState, useRef } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { sendCloud } from "../lib/sendCloud"; +import { useProfileStore } from "../stores/useProfileStore"; + +type Stage = + | { kind: "idle" } + | { kind: "uploading"; loaded: number; total: number } + | { kind: "done"; shareUrl: string; fileName: string; expiresAt: string } + | { kind: "error"; message: string }; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +export default function CloudSharePanel() { + const deviceId = useProfileStore((s) => s.deviceId); + const [showModal, setShowModal] = useState(false); + + return ( + <> + + + {showModal && ( + setShowModal(false)} + /> + )} + + ); +} + +function CloudShareModal({ + deviceId, + onClose, +}: { + deviceId: string; + onClose: () => void; +}) { + const [stage, setStage] = useState({ kind: "idle" }); + const [pickedFile, setPickedFile] = useState(null); + const [email, setEmail] = useState(""); + const [copied, setCopied] = useState(false); + const fileRef = useRef(null); + + const handleSend = async () => { + if (!pickedFile) return; + setStage({ kind: "uploading", loaded: 0, total: pickedFile.size }); + try { + const result = await sendCloud(pickedFile, { + deviceId, + recipientEmail: email.trim() || undefined, + onProgress: (loaded, total) => setStage({ kind: "uploading", loaded, total }), + }); + setStage({ + kind: "done", + shareUrl: result.shareUrl, + fileName: pickedFile.name, + expiresAt: result.expiresAt, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown"; + setStage({ kind: "error", message: msg }); + } + }; + + const handleCopy = () => { + if (stage.kind !== "done") return; + navigator.clipboard.writeText(stage.shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
e.stopPropagation()} + > +
+ Via AnyDrop +
+

+ Send to anyone +

+ + {stage.kind === "idle" && ( + <> + { + const f = e.target.files?.[0]; + if (f) setPickedFile(f); + }} + /> + + {pickedFile ? ( +
+
+ File +
+
+ {pickedFile.name} + + {formatSize(pickedFile.size)} + +
+ +
+ ) : ( + + )} + + + setEmail(e.target.value)} + placeholder="friend@example.com" + className="w-full px-3 py-2.5 bg-paper border border-paper-edge rounded-sm + text-ink text-sm placeholder:text-ink-faint + focus:outline-none focus:border-ink transition-colors + duration-fast ease-crisp mb-5" + /> + +

+ Your file is encrypted locally, then stored on AnyDrop for 7 days. The key never leaves + your browser — only the link's #fragment holds it. +

+ +
+ + +
+ + )} + + {stage.kind === "uploading" && ( + <> +
+ Uploading ciphertext +
+

+ Sealing and uploading… +

+
+
0 ? Math.round((stage.loaded / stage.total) * 100) : 0}%`, + }} + /> +
+

+ {formatSize(stage.loaded)} / {formatSize(stage.total)} +

+ + )} + + {stage.kind === "done" && ( + <> +
+
+ +
+
+
Ready to share
+

+ {stage.fileName} +

+ +

+ {stage.shareUrl} +

+

+ Expires {new Date(stage.expiresAt).toLocaleDateString()}. One download by default — + anyone who has the link can fetch it once. +

+ + + )} + + {stage.kind === "error" && ( + <> +
Failed
+

+ Could not complete the transfer +

+

{stage.message}

+ + + )} +
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index bc4ac56..a42e02f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -67,3 +67,82 @@ export async function unlinkDevice(id: string): Promise { export async function signOut(): Promise { await call("/api/auth/logout", { method: "POST" }); } + +export interface CreateTransferResponse { + transferId: string; + uploadUrl: string; + expiresAt: string; +} + +export interface TransferHead { + transferId: string; + encryptedMetadata: string; + sizeBytes: number; + maxDownloads: number; + downloadCount: number; + expiresAt: string; +} + +export interface InboxTransfer { + id: string; + sizeBytes: number; + encryptedMetadata: string; + createdAt: string; + expiresAt: string; + maxDownloads: number; + downloadCount: number; + firstDownloadAt: string | null; + senderUserId: string | null; + recipientUserId: string | null; + direction: "sent" | "received"; +} + +export async function createTransfer(input: { + sizeBytes: number; + encryptedMetadata: string; + recipientEmail?: string; + maxDownloads?: number; + expiresInDays?: number; + deviceId?: string; +}): Promise { + const res = await call("/api/transfers", { + method: "POST", + body: JSON.stringify(input), + headers: input.deviceId ? { "X-Device-Id": input.deviceId } : {}, + }); + if (!res.ok) throw new Error(`createTransfer failed: ${res.status}`); + return (await res.json()) as CreateTransferResponse; +} + +export async function getTransferHead(id: string): Promise { + const res = await call(`/api/transfers/${encodeURIComponent(id)}`); + if (res.status === 404) throw new Error("transfer_not_found"); + if (res.status === 410) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? "transfer_gone"); + } + if (!res.ok) throw new Error(`getTransferHead failed: ${res.status}`); + return (await res.json()) as TransferHead; +} + +export async function consumeTransfer(id: string): Promise<{ downloadUrl: string }> { + const res = await call(`/api/transfers/${encodeURIComponent(id)}/consume`, { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `consume failed: ${res.status}`); + } + return (await res.json()) as { downloadUrl: string }; +} + +export async function listInboxTransfers(): Promise { + const res = await call("/api/transfers"); + if (res.status === 401) return []; + if (!res.ok) throw new Error(`listTransfers failed: ${res.status}`); + const body = (await res.json()) as { transfers: InboxTransfer[] }; + return body.transfers; +} + +export async function deleteTransfer(id: string): Promise { + const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`); +} diff --git a/web/src/lib/cloudTransfer.ts b/web/src/lib/cloudTransfer.ts new file mode 100644 index 0000000..1f09043 --- /dev/null +++ b/web/src/lib/cloudTransfer.ts @@ -0,0 +1,132 @@ +import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { randomBytes } from "@noble/ciphers/utils.js"; + +/** + * Client-side encryption for cloud relay transfers. + * + * Threat model: the server (AnyDrop backend + MinIO) is honest-but-curious. + * It stores ciphertext and serves it on demand via presigned URLs, but must + * never be able to read filenames, mime types, or file content. + * + * Construction: + * - One random 32-byte key per transfer (XChaCha20-Poly1305). + * - The key lives in the URL fragment (#k=), so browsers never + * send it to our server (fragments are not part of HTTP requests). + * - File content sealed with nonce_1; metadata (JSON {name, mime, size}) + * sealed with nonce_2 — both under the same key. + * + * Why XChaCha20-Poly1305: + * - 24-byte nonce is safe to randomize (birthday collisions negligible). + * - Fast in JS. Single AEAD primitive covers confidentiality + integrity. + */ + +const NONCE_BYTES = 24; + +export interface EncryptedBlob { + /** base64url of nonce || ciphertext || tag (as produced by xchacha20poly1305) */ + ciphertext: Uint8Array; + nonce: Uint8Array; +} + +export interface TransferMetadata { + name: string; + mime: string; + size: number; +} + +export interface SealedTransfer { + key: Uint8Array; + encryptedBody: Uint8Array; + encryptedMetadata: string; +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +function bytesToB64Url(b: Uint8Array): string { + let s = ""; + for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64UrlToBytes(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); + const raw = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +} + +export function generateTransferKey(): Uint8Array { + return randomBytes(32); +} + +export function keyToFragment(key: Uint8Array): string { + return bytesToB64Url(key); +} + +export function fragmentToKey(fragment: string): Uint8Array { + const key = b64UrlToBytes(fragment); + if (key.length !== 32) throw new Error("invalid transfer key"); + return key; +} + +function sealBlob(key: Uint8Array, plaintext: Uint8Array): Uint8Array { + const nonce = randomBytes(NONCE_BYTES); + const cipher = xchacha20poly1305(key, nonce); + const ct = cipher.encrypt(plaintext); + return concat(nonce, ct); +} + +function openBlob(key: Uint8Array, sealed: Uint8Array): Uint8Array { + if (sealed.length < NONCE_BYTES + 16) throw new Error("ciphertext too short"); + const nonce = sealed.subarray(0, NONCE_BYTES); + const ct = sealed.subarray(NONCE_BYTES); + const cipher = xchacha20poly1305(key, nonce); + return cipher.decrypt(ct); +} + +/** + * Encrypts file body and metadata under the same fresh key. + * Returns everything the caller needs: + * - key: put into URL fragment for the recipient + * - encryptedBody: upload to MinIO via presigned PUT + * - encryptedMetadata: base64url string to send with the POST /api/transfers request + */ +export async function sealFile(file: File): Promise { + const key = generateTransferKey(); + const buf = new Uint8Array(await file.arrayBuffer()); + const encryptedBody = sealBlob(key, buf); + + const metadata: TransferMetadata = { + name: file.name, + mime: file.type || "application/octet-stream", + size: file.size, + }; + const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata)); + const encryptedMetadata = bytesToB64Url(sealBlob(key, metadataBytes)); + + return { key, encryptedBody, encryptedMetadata }; +} + +export function openMetadata( + key: Uint8Array, + encryptedMetadataB64: string, +): TransferMetadata { + const sealed = b64UrlToBytes(encryptedMetadataB64); + const plaintext = openBlob(key, sealed); + return JSON.parse(new TextDecoder().decode(plaintext)); +} + +export function openFile( + key: Uint8Array, + encryptedBody: Uint8Array, + metadata: TransferMetadata, +): File { + const plaintext = openBlob(key, encryptedBody); + return new File([plaintext as BlobPart], metadata.name, { type: metadata.mime }); +} diff --git a/web/src/lib/sendCloud.ts b/web/src/lib/sendCloud.ts new file mode 100644 index 0000000..c40b028 --- /dev/null +++ b/web/src/lib/sendCloud.ts @@ -0,0 +1,156 @@ +import { + sealFile, + openMetadata, + openFile, + keyToFragment, + fragmentToKey, + type TransferMetadata, +} from "./cloudTransfer"; +import { + createTransfer, + consumeTransfer, + getTransferHead, + type TransferHead, +} from "./api"; + +export interface SendCloudOptions { + recipientEmail?: string; + expiresInDays?: number; + maxDownloads?: number; + deviceId?: string; + onProgress?: (loaded: number, total: number) => void; +} + +export interface SendCloudResult { + transferId: string; + shareUrl: string; + expiresAt: string; +} + +/** + * End-to-end cloud send: + * 1. Encrypt the file + metadata locally under a fresh random key. + * 2. Register the transfer with the server — sending only ciphertext + * metadata and ciphertext size. Receive a presigned PUT URL. + * 3. Upload ciphertext directly to MinIO (the server never sees it). + * 4. Return a share URL with the key in the fragment. + */ +export async function sendCloud( + file: File, + options: SendCloudOptions = {}, +): Promise { + const { key, encryptedBody, encryptedMetadata } = await sealFile(file); + + const created = await createTransfer({ + sizeBytes: encryptedBody.length, + encryptedMetadata, + recipientEmail: options.recipientEmail, + maxDownloads: options.maxDownloads, + expiresInDays: options.expiresInDays, + deviceId: options.deviceId, + }); + + await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress); + + const origin = + typeof window === "undefined" ? "https://anydrop.arthurbarre.fr" : window.location.origin; + const shareUrl = `${origin}/r/${created.transferId}#k=${keyToFragment(key)}`; + + return { + transferId: created.transferId, + shareUrl, + expiresAt: created.expiresAt, + }; +} + +function uploadWithProgress( + url: string, + body: Uint8Array, + onProgress?: (loaded: number, total: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error(`upload failed: ${xhr.status}`)); + }; + xhr.onerror = () => reject(new Error("upload network error")); + xhr.send(body as BlobPart); + }); +} + +export interface ReceivedTransferPreview { + head: TransferHead; + metadata: TransferMetadata; +} + +export function parseKeyFromLocation(): Uint8Array | null { + if (typeof window === "undefined") return null; + const hash = window.location.hash; + if (!hash.startsWith("#")) return null; + const params = new URLSearchParams(hash.slice(1)); + const k = params.get("k"); + if (!k) return null; + try { + return fragmentToKey(k); + } catch { + return null; + } +} + +/** + * Fetch (but don't consume) the transfer metadata — lets the recipient + * UI render "you're about to accept X MB from sender Y" before committing + * a download slot. + */ +export async function previewTransfer( + transferId: string, + key: Uint8Array, +): Promise { + const head = await getTransferHead(transferId); + const metadata = openMetadata(key, head.encryptedMetadata); + return { head, metadata }; +} + +/** + * Claim a download slot, fetch the ciphertext, decrypt, return a File. + */ +export async function receiveCloud( + transferId: string, + key: Uint8Array, + metadata: TransferMetadata, + onProgress?: (loaded: number, total: number) => void, +): Promise { + const { downloadUrl } = await consumeTransfer(transferId); + + const ciphertext = await downloadWithProgress(downloadUrl, onProgress); + return openFile(key, ciphertext, metadata); +} + +function downloadWithProgress( + url: string, + onProgress?: (loaded: number, total: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.responseType = "arraybuffer"; + xhr.onprogress = (e) => { + if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(new Uint8Array(xhr.response as ArrayBuffer)); + } else { + reject(new Error(`download failed: ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error("download network error")); + xhr.send(); + }); +} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index fffa00f..fe641dc 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -11,6 +11,7 @@ import ReceiveDialog from "../components/ReceiveDialog"; import PublicRoomPanel from "../components/PublicRoomPanel"; import DevicePairingPanel from "../components/DevicePairingPanel"; import ProfileSetup from "../components/ProfileSetup"; +import CloudSharePanel from "../components/CloudSharePanel"; export default function Home() { const isSetUp = useProfileStore((s) => s.isSetUp); @@ -175,21 +176,16 @@ function HomeConnected() { - {/* Phase 2 teaser — "Via AnyDrop" cloud relay */} + {/* Cloud relay — encrypted hand-off via AnyDrop */}
-
-
-
- Coming soon -
-

Via AnyDrop

-

- Send to an email address. The file is held, encrypted, for seven days — the server never sees the key. -

-
-
- Q2 -
+ Via AnyDrop + Send to someone who isn't here + + Sealed in your browser, held on AnyDrop for seven days. The key rides in the link — the server never sees it. + + +
+
diff --git a/web/src/pages/Inbox.tsx b/web/src/pages/Inbox.tsx new file mode 100644 index 0000000..4d965b6 --- /dev/null +++ b/web/src/pages/Inbox.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useAuthStore } from "../stores/useAuthStore"; +import { + listInboxTransfers, + deleteTransfer, + type InboxTransfer, +} from "../lib/api"; + +type Tab = "received" | "sent"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function formatRelative(iso: string): string { + const diffMs = new Date(iso).getTime() - Date.now(); + const hours = Math.round(diffMs / (60 * 60 * 1000)); + if (hours < 0) return "expired"; + if (hours < 1) return "under 1h"; + if (hours < 24) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + +export default function Inbox() { + const user = useAuthStore((s) => s.user); + const loaded = useAuthStore((s) => s.loaded); + const loadUser = useAuthStore((s) => s.loadUser); + + const [transfers, setTransfers] = useState(null); + const [tab, setTab] = useState("received"); + + useEffect(() => { + loadUser(); + }, [loadUser]); + + useEffect(() => { + if (!user) return; + listInboxTransfers().then(setTransfers).catch(() => setTransfers([])); + }, [user]); + + return ( + + {!loaded &&

Loading…

} + + {loaded && !user && ( +
+
+ Sign in required +
+

+ Your inbox is tied to an account +

+

+ Transfers addressed to your email appear here. Anonymous transfers live + only in their share link. +

+ + Go to Account → + +
+ )} + + {loaded && user && ( + <> +
+ setTab("received")}> + Received + + setTab("sent")}> + Sent + +
+ + {transfers === null && ( +

Loading transfers…

+ )} + + {transfers && ( + t.direction === tab)} + direction={tab} + onDelete={async (id) => { + await deleteTransfer(id); + setTransfers((prev) => prev?.filter((t) => t.id !== id) ?? null); + }} + /> + )} + + )} +
+ ); +} + +function TransferList({ + items, + direction, + onDelete, +}: { + items: InboxTransfer[]; + direction: Tab; + onDelete: (id: string) => void | Promise; +}) { + if (items.length === 0) { + return ( +
+
+ +
+

+ {direction === "received" ? "Nothing in your inbox yet" : "No outbound transfers"} +

+

+ {direction === "received" + ? "Transfers sent to your email will show up here." + : "Files you send via AnyDrop will be listed here."} +

+
+ ); + } + + return ( +
    + {items.map((t) => ( + + ))} +
+ ); +} + +function TransferRow({ + transfer, + onDelete, +}: { + transfer: InboxTransfer; + onDelete: (id: string) => void | Promise; +}) { + const remaining = transfer.maxDownloads - transfer.downloadCount; + const isExhausted = remaining <= 0; + const isExpired = new Date(transfer.expiresAt).getTime() < Date.now(); + const unavailable = isExhausted || isExpired; + + return ( +
  • +
    +
    + + {transfer.direction === "sent" ? "Outbound" : "Inbound"} + + {unavailable && ( + + · {isExpired ? "expired" : "consumed"} + + )} +
    +
    + Sealed transfer + + {formatSize(transfer.sizeBytes)} + +
    +
    + {transfer.downloadCount}/{transfer.maxDownloads} downloads + {!unavailable && ` · expires in ${formatRelative(transfer.expiresAt)}`} + {transfer.firstDownloadAt && ` · first opened ${new Date(transfer.firstDownloadAt).toLocaleDateString()}`} +
    +
    + +
  • + ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
    +
    +
    + + ← Back + +

    Inbox

    +
    +
    + {children} +
    +
    + ); +} diff --git a/web/src/pages/Receive.tsx b/web/src/pages/Receive.tsx new file mode 100644 index 0000000..2ae5bb6 --- /dev/null +++ b/web/src/pages/Receive.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + parseKeyFromLocation, + previewTransfer, + receiveCloud, + type ReceivedTransferPreview, +} from "../lib/sendCloud"; + +type Stage = + | { kind: "loading" } + | { kind: "missing-key" } + | { kind: "error"; message: string } + | { kind: "preview"; preview: ReceivedTransferPreview } + | { kind: "downloading"; loaded: number; total: number } + | { kind: "done"; fileName: string }; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function formatExpiry(iso: string): string { + const d = new Date(iso); + const hours = Math.max(0, Math.round((d.getTime() - Date.now()) / (60 * 60 * 1000))); + if (hours < 1) return "in under an hour"; + if (hours < 24) return `in ${hours}h`; + const days = Math.round(hours / 24); + return `in ${days} day${days > 1 ? "s" : ""}`; +} + +function triggerDownload(file: File): void { + const url = URL.createObjectURL(file); + const a = document.createElement("a"); + a.href = url; + a.download = file.name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export default function Receive() { + const { id } = useParams<{ id: string }>(); + const [stage, setStage] = useState({ kind: "loading" }); + const [key, setKey] = useState(null); + + useEffect(() => { + const k = parseKeyFromLocation(); + if (!k) { + setStage({ kind: "missing-key" }); + return; + } + setKey(k); + + if (!id) { + setStage({ kind: "error", message: "transfer_not_found" }); + return; + } + + previewTransfer(id, k) + .then((preview) => setStage({ kind: "preview", preview })) + .catch((err) => { + const msg = err instanceof Error ? err.message : "unknown"; + setStage({ kind: "error", message: msg }); + }); + }, [id]); + + const accept = async () => { + if (!id || !key || stage.kind !== "preview") return; + setStage({ kind: "downloading", loaded: 0, total: stage.preview.head.sizeBytes }); + try { + const file = await receiveCloud(id, key, stage.preview.metadata, (loaded, total) => { + setStage({ kind: "downloading", loaded, total }); + }); + triggerDownload(file); + setStage({ kind: "done", fileName: stage.preview.metadata.name }); + } catch (err) { + const msg = err instanceof Error ? err.message : "unknown"; + setStage({ kind: "error", message: msg }); + } + }; + + return ( +
    +
    +
    +
    + Via AnyDrop +
    +

    + You've been sent something +

    +
    + + + + +
    +
    + ); +} + +function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }) { + if (stage.kind === "loading") { + return

    Decrypting preview…

    ; + } + + if (stage.kind === "missing-key") { + return ( +
    +
    Missing key
    +

    + This link is incomplete +

    +

    + The decryption key lives in the URL fragment (after the #). + It looks like it was stripped in transit. Ask the sender to share the full link again. +

    +
    + ); + } + + if (stage.kind === "error") { + const pretty = + stage.message === "transfer_not_found" + ? "This transfer no longer exists." + : stage.message === "expired" + ? "This transfer has expired." + : stage.message === "consumed" || stage.message === "not_available" + ? "This transfer has already been downloaded." + : "Something went wrong."; + return ( +
    +
    Unavailable
    +

    {pretty}

    +

    + {stage.message} +

    +
    + ); + } + + if (stage.kind === "preview") { + const { metadata, head } = stage.preview; + const remainingDownloads = head.maxDownloads - head.downloadCount; + return ( +
    +
    Ready to download
    +

    + {metadata.name} +

    + +
    +
    +
    Size
    +
    {formatSize(metadata.size)}
    +
    +
    +
    Expires
    +
    {formatExpiry(head.expiresAt)}
    +
    +
    +
    Downloads
    +
    + {head.downloadCount}/{head.maxDownloads} +
    +
    +
    + + + +

    + {remainingDownloads === 1 + ? "This is the last available download." + : `${remainingDownloads} downloads remaining.`} +

    +
    + ); + } + + if (stage.kind === "downloading") { + const pct = stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0; + return ( +
    +
    Downloading
    +

    + Pulling the ciphertext… +

    +
    +
    +
    +

    + {formatSize(stage.loaded)} / {formatSize(stage.total)} · {pct}% +

    +
    + ); + } + + return ( +
    +
    Done
    +

    + Saved locally +

    +

    + {stage.fileName} has been decrypted in your browser and + downloaded. The ciphertext on AnyDrop is being purged. +

    +
    + ); +}