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:
ordinarthur 2026-04-20 11:09:58 +02:00
parent c18d995c3f
commit 0b639dfc3c
23 changed files with 3539 additions and 16 deletions

138
k8s/minio.yml Normal file
View 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

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View 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");

View 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": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1776644472089,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1776675064100,
"tag": "0001_loving_yellowjacket",
"breakpoints": true
}
]
}

View File

@ -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;

View File

@ -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) => {

View 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);
});

View File

@ -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");
}

View 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
View 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;
}
}

View File

@ -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",

View File

@ -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>
);

View 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>
);
}

View File

@ -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}`);
}

View 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
View 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();
});
}

View File

@ -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
View 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
View 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>
);
}