feat: encrypted cloud relay (Phase 2)
Adds a "Via AnyDrop" flow for senders who need to reach someone not present on the mesh. The file is sealed client-side (XChaCha20-Poly1305), uploaded directly to an in-cluster MinIO bucket via a presigned PUT, and handed off to the recipient as a URL whose fragment carries the key. The server only ever sees ciphertext, opaque metadata blobs, and sizes. - server: transfers table (drizzle migration), /api/transfers CRUD + consume endpoint, presigned PUT/GET via @aws-sdk/client-s3, cleanup loop that purges expired + exhausted blobs. - web: @noble/ciphers sealFile/openFile, high-level sendCloud/receive helpers, CloudSharePanel on Home, /r/:id receive page, /inbox page for signed-in users (sent + received tabs). - k8s: MinIO StatefulSet with bucket-init initContainer, S3 env vars on the server Deployment (credentials pulled from minio-credentials Secret). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c18d995c3f
commit
0b639dfc3c
138
k8s/minio.yml
Normal file
138
k8s/minio.yml
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -15,6 +15,14 @@ data:
|
||||
SMTP_SECURE: "false"
|
||||
SMTP_TLS_REJECT_UNAUTHORIZED: "false"
|
||||
SMTP_FROM: "AnyDrop <noreply@anydrop.arthurbarre.fr>"
|
||||
# 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
|
||||
|
||||
1231
pnpm-lock.yaml
generated
1231
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
22
server/src/db/migrations/0001_loving_yellowjacket.sql
Normal file
22
server/src/db/migrations/0001_loving_yellowjacket.sql
Normal file
@ -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");
|
||||
553
server/src/db/migrations/meta/0001_snapshot.json
Normal file
553
server/src/db/migrations/meta/0001_snapshot.json
Normal file
@ -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": {}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,13 @@
|
||||
"when": 1776644472089,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1776675064100,
|
||||
"tag": "0001_loving_yellowjacket",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
244
server/src/http/transfers.ts
Normal file
244
server/src/http/transfers.ts
Normal file
@ -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);
|
||||
});
|
||||
@ -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");
|
||||
}
|
||||
|
||||
56
server/src/storage/cleanup.ts
Normal file
56
server/src/storage/cleanup.ts
Normal file
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
81
server/src/storage/s3.ts
Normal file
81
server/src/storage/s3.ts
Normal file
@ -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<string> {
|
||||
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<string> {
|
||||
const cmd = new GetObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: storageKey,
|
||||
});
|
||||
return getSignedUrl(getS3Client(), cmd, { expiresIn: ttlSeconds });
|
||||
}
|
||||
|
||||
export async function deleteObject(storageKey: string): Promise<void> {
|
||||
await getS3Client().send(new DeleteObjectCommand({ Bucket: BUCKET, Key: storageKey }));
|
||||
}
|
||||
|
||||
export async function objectExists(storageKey: string): Promise<boolean> {
|
||||
try {
|
||||
await getS3Client().send(new HeadObjectCommand({ Bucket: BUCKET, Key: storageKey }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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() {
|
||||
<Route path="/share" element={<Share />} />
|
||||
<Route path="/pair" element={<Pair />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/inbox" element={<Inbox />} />
|
||||
<Route path="/r/:id" element={<Receive />} />
|
||||
<Route path="/:code" element={<JoinRoom />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
273
web/src/components/CloudSharePanel.tsx
Normal file
273
web/src/components/CloudSharePanel.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="paper-panel px-4 py-4 flex flex-col items-start gap-1
|
||||
hover:border-ink transition-colors duration-fast ease-crisp
|
||||
text-left"
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||
Cloud drop
|
||||
</span>
|
||||
<span className="text-sm text-ink">Send to anyone →</span>
|
||||
</button>
|
||||
|
||||
{showModal && (
|
||||
<CloudShareModal
|
||||
deviceId={deviceId}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudShareModal({
|
||||
deviceId,
|
||||
onClose,
|
||||
}: {
|
||||
deviceId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [stage, setStage] = useState<Stage>({ kind: "idle" });
|
||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="paper-panel shadow-lift rounded-sm p-6 max-w-md w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||
Via AnyDrop
|
||||
</div>
|
||||
<h3 className="font-display text-2xl text-ink mt-1 mb-5 tracking-tight">
|
||||
Send to anyone
|
||||
</h3>
|
||||
|
||||
{stage.kind === "idle" && (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) setPickedFile(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pickedFile ? (
|
||||
<div className="paper-panel-deep border-paper-edge rounded-sm px-4 py-3 mb-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||
File
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-sm text-ink truncate mr-3">{pickedFile.name}</span>
|
||||
<span className="font-mono text-xs text-ink-muted whitespace-nowrap">
|
||||
{formatSize(pickedFile.size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="mt-2 text-xs text-ink-muted hover:text-ink transition-colors"
|
||||
>
|
||||
Pick another
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="w-full border border-dashed border-paper-edge hover:border-ink
|
||||
bg-paper rounded-sm px-4 py-8 mb-4
|
||||
flex flex-col items-center gap-2
|
||||
transition-colors duration-fast ease-crisp"
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
|
||||
Pick
|
||||
</span>
|
||||
<span className="font-display text-xl text-ink">Choose a file</span>
|
||||
<span className="text-xs text-ink-muted">Up to 2 GB</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label className="block text-xs uppercase tracking-[0.15em] text-ink-muted mb-1.5">
|
||||
Recipient email <span className="text-ink-faint normal-case tracking-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-ink-muted leading-relaxed mb-5">
|
||||
Your file is encrypted locally, then stored on AnyDrop for 7 days. The key never leaves
|
||||
your browser — only the link's <code className="mono text-ink">#fragment</code> holds it.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 border border-paper-edge hover:border-ink
|
||||
text-sm text-ink rounded-sm transition-colors
|
||||
duration-fast ease-crisp"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!pickedFile}
|
||||
className="flex-1 py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
||||
hover:bg-signal transition-colors duration-fast ease-crisp
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Encrypt & send →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage.kind === "uploading" && (
|
||||
<>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted mb-2">
|
||||
Uploading ciphertext
|
||||
</div>
|
||||
<p className="font-display text-xl text-ink mb-5">
|
||||
Sealing and uploading…
|
||||
</p>
|
||||
<div className="h-px bg-paper-edge overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-signal transition-all duration-200"
|
||||
style={{
|
||||
width: `${stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-xs text-ink-muted">
|
||||
{formatSize(stage.loaded)} / {formatSize(stage.total)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage.kind === "done" && (
|
||||
<>
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="bg-paper p-3 border border-paper-edge rounded-sm">
|
||||
<QRCodeSVG
|
||||
value={stage.shareUrl}
|
||||
size={180}
|
||||
bgColor="#F5F0E6"
|
||||
fgColor="#1A1714"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ok">Ready to share</div>
|
||||
<h3 className="font-display text-xl text-ink mt-1 mb-3 tracking-tight">
|
||||
{stage.fileName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
||||
hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||
>
|
||||
{copied ? "Copied ✓" : "Copy link"}
|
||||
</button>
|
||||
<p className="mt-3 font-mono text-xs text-ink-muted break-all">
|
||||
{stage.shareUrl}
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-ink-muted leading-relaxed">
|
||||
Expires {new Date(stage.expiresAt).toLocaleDateString()}. One download by default —
|
||||
anyone who has the link can fetch it once.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-5 w-full py-2.5 border border-paper-edge hover:border-ink
|
||||
text-sm text-ink rounded-sm transition-colors
|
||||
duration-fast ease-crisp"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage.kind === "error" && (
|
||||
<>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-fail">Failed</div>
|
||||
<h3 className="font-display text-xl text-ink mt-1 mb-3">
|
||||
Could not complete the transfer
|
||||
</h3>
|
||||
<p className="font-mono text-xs text-ink-muted">{stage.message}</p>
|
||||
<button
|
||||
onClick={() => setStage({ kind: "idle" })}
|
||||
className="mt-5 w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
||||
hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -67,3 +67,82 @@ export async function unlinkDevice(id: string): Promise<void> {
|
||||
export async function signOut(): Promise<void> {
|
||||
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<CreateTransferResponse> {
|
||||
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<TransferHead> {
|
||||
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<InboxTransfer[]> {
|
||||
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<void> {
|
||||
const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`);
|
||||
}
|
||||
|
||||
132
web/src/lib/cloudTransfer.ts
Normal file
132
web/src/lib/cloudTransfer.ts
Normal file
@ -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=<base64url>), 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<SealedTransfer> {
|
||||
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 });
|
||||
}
|
||||
156
web/src/lib/sendCloud.ts
Normal file
156
web/src/lib/sendCloud.ts
Normal file
@ -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<SendCloudResult> {
|
||||
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<void> {
|
||||
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<ReceivedTransferPreview> {
|
||||
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<File> {
|
||||
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<Uint8Array> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@ -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() {
|
||||
<TransferProgress />
|
||||
</section>
|
||||
|
||||
{/* Phase 2 teaser — "Via AnyDrop" cloud relay */}
|
||||
{/* Cloud relay — encrypted hand-off via AnyDrop */}
|
||||
<section className="mb-14">
|
||||
<div className="paper-panel-deep px-5 py-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||
Coming soon
|
||||
</div>
|
||||
<h3 className="font-display text-xl mt-2 text-ink">Via AnyDrop</h3>
|
||||
<p className="text-sm text-ink-muted mt-1 leading-relaxed">
|
||||
Send to an email address. The file is held, encrypted, for seven days — the server never sees the key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-ink-faint uppercase tracking-widest shrink-0 pt-1">
|
||||
Q2
|
||||
</div>
|
||||
<SectionLabel>Via AnyDrop</SectionLabel>
|
||||
<SectionTitle>Send to someone who isn't here</SectionTitle>
|
||||
<SectionLead>
|
||||
Sealed in your browser, held on AnyDrop for seven days. The key rides in the link — the server never sees it.
|
||||
</SectionLead>
|
||||
|
||||
<div className="mt-6">
|
||||
<CloudSharePanel />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
226
web/src/pages/Inbox.tsx
Normal file
226
web/src/pages/Inbox.tsx
Normal file
@ -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<InboxTransfer[] | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("received");
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
listInboxTransfers().then(setTransfers).catch(() => setTransfers([]));
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
{!loaded && <p className="text-sm text-ink-muted">Loading…</p>}
|
||||
|
||||
{loaded && !user && (
|
||||
<div className="paper-panel px-6 py-6">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||
Sign in required
|
||||
</div>
|
||||
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
|
||||
Your inbox is tied to an account
|
||||
</h2>
|
||||
<p className="text-sm text-ink-muted leading-relaxed mb-5">
|
||||
Transfers addressed to your email appear here. Anonymous transfers live
|
||||
only in their share link.
|
||||
</p>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-block bg-ink text-paper text-sm font-medium rounded-sm
|
||||
px-4 py-2.5 hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||
>
|
||||
Go to Account →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loaded && user && (
|
||||
<>
|
||||
<div className="flex gap-6 pb-4 mb-6 rule">
|
||||
<TabButton active={tab === "received"} onClick={() => setTab("received")}>
|
||||
Received
|
||||
</TabButton>
|
||||
<TabButton active={tab === "sent"} onClick={() => setTab("sent")}>
|
||||
Sent
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{transfers === null && (
|
||||
<p className="text-sm text-ink-muted">Loading transfers…</p>
|
||||
)}
|
||||
|
||||
{transfers && (
|
||||
<TransferList
|
||||
items={transfers.filter((t) => t.direction === tab)}
|
||||
direction={tab}
|
||||
onDelete={async (id) => {
|
||||
await deleteTransfer(id);
|
||||
setTransfers((prev) => prev?.filter((t) => t.id !== id) ?? null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferList({
|
||||
items,
|
||||
direction,
|
||||
onDelete,
|
||||
}: {
|
||||
items: InboxTransfer[];
|
||||
direction: Tab;
|
||||
onDelete: (id: string) => void | Promise<void>;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="paper-panel px-6 py-10 text-center">
|
||||
<div className="w-10 h-10 mx-auto mb-4 rounded-full border border-paper-edge flex items-center justify-center">
|
||||
<span className="w-2 h-2 rounded-full bg-paper-edge" />
|
||||
</div>
|
||||
<p className="font-display text-xl text-ink mb-2">
|
||||
{direction === "received" ? "Nothing in your inbox yet" : "No outbound transfers"}
|
||||
</p>
|
||||
<p className="text-sm text-ink-muted leading-relaxed max-w-xs mx-auto">
|
||||
{direction === "received"
|
||||
? "Transfers sent to your email will show up here."
|
||||
: "Files you send via AnyDrop will be listed here."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="paper-panel divide-y divide-paper-edge">
|
||||
{items.map((t) => (
|
||||
<TransferRow key={t.id} transfer={t} onDelete={onDelete} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferRow({
|
||||
transfer,
|
||||
onDelete,
|
||||
}: {
|
||||
transfer: InboxTransfer;
|
||||
onDelete: (id: string) => void | Promise<void>;
|
||||
}) {
|
||||
const remaining = transfer.maxDownloads - transfer.downloadCount;
|
||||
const isExhausted = remaining <= 0;
|
||||
const isExpired = new Date(transfer.expiresAt).getTime() < Date.now();
|
||||
const unavailable = isExhausted || isExpired;
|
||||
|
||||
return (
|
||||
<li className="px-5 py-4 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
|
||||
{transfer.direction === "sent" ? "Outbound" : "Inbound"}
|
||||
</span>
|
||||
{unavailable && (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-faint">
|
||||
· {isExpired ? "expired" : "consumed"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-ink">
|
||||
Sealed transfer
|
||||
<span className="ml-2 font-mono text-xs text-ink-muted">
|
||||
{formatSize(transfer.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-ink-faint uppercase tracking-widest">
|
||||
{transfer.downloadCount}/{transfer.maxDownloads} downloads
|
||||
{!unavailable && ` · expires in ${formatRelative(transfer.expiresAt)}`}
|
||||
{transfer.firstDownloadAt && ` · first opened ${new Date(transfer.firstDownloadAt).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(transfer.id)}
|
||||
className="shrink-0 text-xs text-ink-muted hover:text-signal transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-sm pb-1 -mb-[1px] border-b-2 transition-colors duration-fast ease-crisp ${
|
||||
active
|
||||
? "border-ink text-ink"
|
||||
: "border-transparent text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
||||
<header className="flex items-center justify-between pb-8 mb-10 rule">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-ink-muted hover:text-ink transition-colors duration-fast"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="font-display text-xl text-ink tracking-tight">Inbox</h1>
|
||||
<div className="w-10" />
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
web/src/pages/Receive.tsx
Normal file
231
web/src/pages/Receive.tsx
Normal file
@ -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<Stage>({ kind: "loading" });
|
||||
const [key, setKey] = useState<Uint8Array | null>(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 (
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
||||
<header className="pb-8 mb-10 rule">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||
Via AnyDrop
|
||||
</div>
|
||||
<h1 className="font-display text-4xl leading-none tracking-tight text-ink mt-2">
|
||||
You've been sent something
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<ReceiveBody stage={stage} onAccept={accept} />
|
||||
|
||||
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
||||
<span>End-to-end encrypted · The server never sees the key</span>
|
||||
<a
|
||||
href="/"
|
||||
className="text-ink hover:text-signal transition-colors duration-fast"
|
||||
>
|
||||
AnyDrop →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }) {
|
||||
if (stage.kind === "loading") {
|
||||
return <p className="text-sm text-ink-muted">Decrypting preview…</p>;
|
||||
}
|
||||
|
||||
if (stage.kind === "missing-key") {
|
||||
return (
|
||||
<div className="paper-panel px-6 py-6">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-fail">Missing key</div>
|
||||
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
|
||||
This link is incomplete
|
||||
</h2>
|
||||
<p className="text-sm text-ink-muted leading-relaxed">
|
||||
The decryption key lives in the URL fragment (after the <code className="mono text-ink">#</code>).
|
||||
It looks like it was stripped in transit. Ask the sender to share the full link again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="paper-panel px-6 py-6">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-fail">Unavailable</div>
|
||||
<h2 className="font-display text-2xl text-ink mt-2">{pretty}</h2>
|
||||
<p className="font-mono text-xs text-ink-faint mt-3 uppercase tracking-widest">
|
||||
{stage.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage.kind === "preview") {
|
||||
const { metadata, head } = stage.preview;
|
||||
const remainingDownloads = head.maxDownloads - head.downloadCount;
|
||||
return (
|
||||
<div className="paper-panel px-6 py-6">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">Ready to download</div>
|
||||
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
||||
{metadata.name}
|
||||
</h2>
|
||||
|
||||
<dl className="grid grid-cols-3 gap-4 border-t border-b border-paper-edge py-4">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.15em] text-ink-muted">Size</dt>
|
||||
<dd className="font-mono text-sm text-ink mt-1">{formatSize(metadata.size)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.15em] text-ink-muted">Expires</dt>
|
||||
<dd className="text-sm text-ink mt-1">{formatExpiry(head.expiresAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-[0.15em] text-ink-muted">Downloads</dt>
|
||||
<dd className="font-mono text-sm text-ink mt-1">
|
||||
{head.downloadCount}/{head.maxDownloads}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<button
|
||||
onClick={onAccept}
|
||||
className="mt-6 w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
|
||||
hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||
>
|
||||
Download & decrypt →
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-xs text-ink-muted leading-relaxed text-center">
|
||||
{remainingDownloads === 1
|
||||
? "This is the last available download."
|
||||
: `${remainingDownloads} downloads remaining.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage.kind === "downloading") {
|
||||
const pct = stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0;
|
||||
return (
|
||||
<div className="paper-panel px-6 py-6">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">Downloading</div>
|
||||
<h2 className="font-display text-2xl text-ink mt-2 mb-5">
|
||||
Pulling the ciphertext…
|
||||
</h2>
|
||||
<div className="h-px bg-paper-edge overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-signal transition-all duration-200"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-xs text-ink-muted">
|
||||
{formatSize(stage.loaded)} / {formatSize(stage.total)} · {pct}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="paper-panel px-6 py-6">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ok">Done</div>
|
||||
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
|
||||
Saved locally
|
||||
</h2>
|
||||
<p className="text-sm text-ink-muted leading-relaxed">
|
||||
<span className="text-ink">{stage.fileName}</span> has been decrypted in your browser and
|
||||
downloaded. The ciphertext on AnyDrop is being purged.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user