feat: stealth accounts + data layer (Phase 1)
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 1m47s

This commit is contained in:
ordinarthur 2026-04-20 09:57:22 +02:00
parent acda1a8bb8
commit 2913618ee6
39 changed files with 8312 additions and 9554 deletions

View File

@ -0,0 +1,37 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"Bash(node_modules/.bin/drizzle-kit generate *)",
"Bash(../node_modules/.bin/drizzle-kit generate *)",
"Bash(npm view *)",
"Bash(NODE_PATH=/Users/arthurbarre/dev/perso/anydrop/server/node_modules:/Users/arthurbarre/dev/perso/anydrop/node_modules node /Users/arthurbarre/dev/perso/anydrop/node_modules/drizzle-kit/bin.cjs generate --name=init)",
"Bash(node ../node_modules/drizzle-kit/bin.cjs generate --name=init)",
"Bash(node --experimental-vm-modules -e \"import\\('drizzle-orm/version'\\).then\\(v=>console.log\\('ok:',v.compatibilityVersion,v.npmVersion\\)\\).catch\\(e=>console.log\\('err:',e.message\\)\\)\")",
"Bash(node /Users/arthurbarre/dev/perso/anydrop/node_modules/drizzle-kit/bin.cjs generate --name=init)",
"Bash(pnpm --version)",
"Bash(pnpm exec *)",
"Bash(docker compose *)",
"Bash(docker exec *)",
"Bash(pnpm run *)",
"Bash(pnpm --filter @anydrop/server run typecheck)",
"Bash(pnpm --filter @anydrop/server list @hono/node-server hono)",
"Bash(DATABASE_URL=postgres://anydrop:anydrop@localhost:5433/anydrop SESSION_SECRET=this_is_a_dev_secret_at_least_32_chars_long APP_URL=http://localhost:5173 SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=\"AnyDrop <noreply@anydrop.local>\" pnpm dev)",
"Bash(echo \"pid=$!\")",
"Bash(curl -s \"http://localhost:8025/api/v1/message/latest\")",
"Bash(pkill -f \"tsx watch src/index.ts\")",
"Bash(pnpm typecheck *)",
"Bash(git -C /Users/arthurbarre/dev/perso/anydrop status --short)",
"Bash(pnpm --filter @anydrop/shared run build)",
"Bash(pnpm --filter @anydrop/server run build)",
"Bash(pnpm --filter @anydrop/web exec vite build)",
"Bash(node server/dist/db/migrate.js)",
"Bash(pnpm --filter @anydrop/server dev)",
"Bash(curl -sS -o /dev/null -X POST http://localhost:3001/api/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"__TRACKED_VAR__\"}')",
"Bash(curl -sS \"http://localhost:8025/api/v1/messages\")",
"Bash(python3 -m json.tool)",
"Bash(curl -sS -b /tmp/anydrop-cookie.txt -X POST http://localhost:3001/api/devices -H 'Content-Type: application/json' -d '{\"deviceId\":\"dev-verify-e2e\",\"name\":\"E2E Verify\",\"type\":\"laptop\",\"avatar\":null}')",
"Bash(kill %1)"
]
}
}

View File

@ -68,7 +68,10 @@ jobs:
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \ --docker-password=${{ secrets.REGISTRY_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
# Apply manifests # Apply manifests (data layer first, app last)
kubectl apply -f k8s/postgres.yml
kubectl apply -f k8s/maddy.yml
kubectl -n $NAMESPACE rollout status statefulset/postgres --timeout=180s
kubectl apply -f k8s/server.yml kubectl apply -f k8s/server.yml
kubectl apply -f k8s/web.yml kubectl apply -f k8s/web.yml

201
k8s/maddy.yml Normal file
View File

@ -0,0 +1,201 @@
# ---------------------------------------------------------------------------
# Maddy — self-hosted SMTP server for AnyDrop magic-link emails.
#
# BEFORE FIRST DEPLOYMENT, the following DNS records must exist for
# anydrop.arthurbarre.fr (OVH):
# - A mail.anydrop.arthurbarre.fr → <cluster public IP>
# - MX 10 anydrop.arthurbarre.fr → mail.anydrop.arthurbarre.fr
# - TXT anydrop.arthurbarre.fr → "v=spf1 a mx ~all"
# - TXT _dmarc.anydrop.arthurbarre.fr → "v=DMARC1; p=none; rua=mailto:arthurbarre.js@gmail.com"
#
# The DKIM public key is generated by maddy on first start and stored in the
# PVC. Extract it once with:
# kubectl -n anydrop exec -it deploy/maddy -- cat /data/dkim_keys/anydrop.arthurbarre.fr_default.dns
# Then publish it as:
# TXT default._domainkey.anydrop.arthurbarre.fr → <public key record>
#
# Also make sure PTR (reverse DNS) for the public IP points to
# mail.anydrop.arthurbarre.fr — configure at the Proxmox / ISP level.
# ---------------------------------------------------------------------------
apiVersion: v1
kind: ConfigMap
metadata:
name: maddy-config
namespace: anydrop
data:
maddy.conf: |
$(hostname) = mail.anydrop.arthurbarre.fr
$(primary_domain) = anydrop.arthurbarre.fr
$(local_domains) = $(primary_domain)
tls off
# -------------------------------------------------------------------
# Outbound pipeline — sign with DKIM, send directly to destination MX.
# -------------------------------------------------------------------
(local_routing) {
destination postmaster $(local_domains) {
reject 550 5.1.1 "No local mailboxes — outbound only"
}
default_destination {
modify {
dkim $(primary_domain) default (1024)
}
deliver_to &remote_queue
}
}
target.queue remote_queue {
target &remote_delivery
max_parallelism 16
max_tries 20
}
target.remote remote_delivery {
limits {
destination rate 20 1s
destination concurrency 10
}
mx_auth {
dane
mtasts {
cache fs
fs_dir mtasts_cache/
}
local_policy {
min_tls_level none
min_mx_level none
}
}
}
# -------------------------------------------------------------------
# SMTP submission endpoint (internal only — cluster-ip service).
# No TLS required in-cluster; the server process talks to maddy over
# the flat pod network.
# -------------------------------------------------------------------
smtp tcp://0.0.0.0:587 {
limits {
all rate 100 1s
all concurrency 50
}
source $(local_domains) {
reject 501 5.1.8 "Non-local sender refused"
}
default_source {
destination postmaster $(local_domains) {
reject 550 5.1.1 "Cannot send to local — outbound only"
}
default_destination {
modify {
dkim $(primary_domain) default (1024)
}
deliver_to &remote_queue
}
}
}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: maddy-data
namespace: anydrop
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: maddy
namespace: anydrop
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: maddy
template:
metadata:
labels:
app: maddy
spec:
containers:
- name: maddy
image: foxcpp/maddy:0.8
args: ["-config", "/etc/maddy/maddy.conf"]
ports:
- containerPort: 587
name: submission
- containerPort: 25
name: smtp
volumeMounts:
- name: config
mountPath: /etc/maddy
- name: data
mountPath: /data
workingDir: /data
readinessProbe:
tcpSocket:
port: 587
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 587
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
volumes:
- name: config
configMap:
name: maddy-config
- name: data
persistentVolumeClaim:
claimName: maddy-data
---
apiVersion: v1
kind: Service
metadata:
name: maddy
namespace: anydrop
spec:
type: ClusterIP
selector:
app: maddy
ports:
- name: submission
port: 587
targetPort: 587
---
# Optional: expose port 25 on a NodePort if you want maddy to also receive
# inbound mail (bounces, replies). For Phase 1 outbound-only, this can stay
# commented out — direct-to-MX delivery does not require inbound.
#
# apiVersion: v1
# kind: Service
# metadata:
# name: maddy-smtp
# namespace: anydrop
# spec:
# type: NodePort
# selector:
# app: maddy
# ports:
# - name: smtp
# port: 25
# targetPort: 25
# nodePort: 30025

84
k8s/postgres.yml Normal file
View File

@ -0,0 +1,84 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: anydrop
labels:
app: postgres
spec:
clusterIP: None
selector:
app: postgres
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: anydrop
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
value: anydrop
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: postgres-credentials
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-credentials
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command: ["pg_isready", "-U", "anydrop", "-d", "anydrop"]
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
readinessProbe:
exec:
command: ["pg_isready", "-U", "anydrop", "-d", "anydrop"]
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi

41
k8s/secrets.example.yml Normal file
View File

@ -0,0 +1,41 @@
# ---------------------------------------------------------------------------
# Template for cluster secrets. DO NOT commit the real file.
#
# To create the real secrets on the cluster:
#
# # Postgres — generate a strong password
# POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '=+/')
# kubectl -n anydrop create secret generic postgres-credentials \
# --from-literal=username=anydrop \
# --from-literal=password="$POSTGRES_PASSWORD"
#
# # App secrets — session signing + DB URL
# SESSION_SECRET=$(openssl rand -base64 64 | tr -d '=+/')
# DATABASE_URL="postgres://anydrop:${POSTGRES_PASSWORD}@postgres.anydrop.svc.cluster.local:5432/anydrop"
# kubectl -n anydrop create secret generic anydrop-app-secrets \
# --from-literal=SESSION_SECRET="$SESSION_SECRET" \
# --from-literal=DATABASE_URL="$DATABASE_URL"
#
# Rotate by replacing the secret and restarting the pods:
# kubectl -n anydrop rollout restart deployment/anydrop-server
# ---------------------------------------------------------------------------
apiVersion: v1
kind: Secret
metadata:
name: postgres-credentials
namespace: anydrop
type: Opaque
stringData:
username: anydrop
password: CHANGE_ME_STRONG_PASSWORD
---
apiVersion: v1
kind: Secret
metadata:
name: anydrop-app-secrets
namespace: anydrop
type: Opaque
stringData:
SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING
DATABASE_URL: postgres://anydrop:CHANGE_ME@postgres.anydrop.svc.cluster.local:5432/anydrop

View File

@ -6,9 +6,15 @@ metadata:
data: data:
PORT: "3001" PORT: "3001"
BASE_URL: "https://anydrop.arthurbarre.fr" BASE_URL: "https://anydrop.arthurbarre.fr"
APP_URL: "https://anydrop.arthurbarre.fr"
VAPID_PUBLIC_KEY: "BCta0SNLmjBFfizMInnBhEQvVZlMbbaM-qw1a-p3JeQykCyy00GRGkDAKMDA5nv5UfokwJ30HRGoA6buJjWwKcE" VAPID_PUBLIC_KEY: "BCta0SNLmjBFfizMInnBhEQvVZlMbbaM-qw1a-p3JeQykCyy00GRGkDAKMDA5nv5UfokwJ30HRGoA6buJjWwKcE"
VAPID_PRIVATE_KEY: "gbmrcm9Tuz4JgoHophO-jUbam8rV9YgjImYcWvoE0w0" VAPID_PRIVATE_KEY: "gbmrcm9Tuz4JgoHophO-jUbam8rV9YgjImYcWvoE0w0"
VAPID_SUBJECT: "mailto:arthurbarre.js@gmail.com" VAPID_SUBJECT: "mailto:arthurbarre.js@gmail.com"
SMTP_HOST: "maddy.anydrop.svc.cluster.local"
SMTP_PORT: "587"
SMTP_SECURE: "false"
SMTP_TLS_REJECT_UNAUTHORIZED: "false"
SMTP_FROM: "AnyDrop <noreply@anydrop.arthurbarre.fr>"
--- ---
apiVersion: apps/v1 apiVersion: apps/v1
@ -26,6 +32,15 @@ spec:
labels: labels:
app: anydrop-server app: anydrop-server
spec: spec:
initContainers:
- name: db-migrate
image: git.arthurbarre.fr/ordinarthur/anydrop-server:latest
command: ["node", "server/dist/db/migrate.js"]
envFrom:
- configMapRef:
name: anydrop-server-config
- secretRef:
name: anydrop-app-secrets
containers: containers:
- name: anydrop-server - name: anydrop-server
image: git.arthurbarre.fr/ordinarthur/anydrop-server:latest image: git.arthurbarre.fr/ordinarthur/anydrop-server:latest
@ -34,6 +49,8 @@ spec:
envFrom: envFrom:
- configMapRef: - configMapRef:
name: anydrop-server-config name: anydrop-server-config
- secretRef:
name: anydrop-app-secrets
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /health path: /health
@ -48,11 +65,11 @@ spec:
periodSeconds: 10 periodSeconds: 10
resources: resources:
requests: requests:
memory: "64Mi" memory: "96Mi"
cpu: "50m" cpu: "50m"
limits: limits:
memory: "128Mi" memory: "192Mi"
cpu: "200m" cpu: "300m"
imagePullSecrets: imagePullSecrets:
- name: gitea-registry - name: gitea-registry

9514
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,17 +3,13 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Universal file & text sharing — peer-to-peer, no account required", "description": "Universal file & text sharing — peer-to-peer, no account required",
"workspaces": [
"shared",
"server",
"web"
],
"scripts": { "scripts": {
"dev": "concurrently -n server,web -c blue,green \"cd server && tsx watch src/index.ts\" \"cd web && vite\"", "dev": "concurrently -n server,web -c blue,green \"pnpm --filter @anydrop/server dev\" \"pnpm --filter @anydrop/web dev\"",
"dev:web": "cd web && vite", "dev:web": "pnpm --filter @anydrop/web dev",
"dev:server": "cd server && tsx watch src/index.ts", "dev:server": "pnpm --filter @anydrop/server dev",
"build": "tsc -b shared && tsc -b server && cd web && vite build", "dev:services": "docker compose -f server/docker-compose.dev.yml up -d",
"typecheck": "tsc -b shared && tsc -b server && cd web && tsc --noEmit" "build": "tsc -b shared && tsc -b server && pnpm --filter @anydrop/web build",
"typecheck": "tsc -b shared && tsc -b server && pnpm --filter @anydrop/web exec tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2", "concurrently": "^9.1.2",

6489
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,4 @@
packages:
- shared
- server
- web

View File

@ -1,30 +1,33 @@
# Build stage # Build stage
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
COPY package.json package-lock.json ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY tsconfig.base.json ./
COPY shared/package.json shared/ COPY shared/package.json shared/
COPY server/package.json server/ COPY server/package.json server/
COPY web/package.json web/ COPY web/package.json web/
RUN npm ci RUN pnpm install --frozen-lockfile
COPY tsconfig.base.json ./
COPY shared/ shared/ COPY shared/ shared/
COPY server/ server/ COPY server/ server/
RUN npm run build -w shared && npm run build -w server RUN pnpm --filter @anydrop/shared run build && pnpm --filter @anydrop/server run build
# Runtime stage # Runtime stage
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
COPY package.json package-lock.json ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY shared/package.json shared/ COPY shared/package.json shared/
COPY server/package.json server/ COPY server/package.json server/
COPY web/package.json web/ COPY web/package.json web/
RUN npm ci --omit=dev RUN pnpm install --frozen-lockfile --prod --filter @anydrop/server... --ignore-scripts
COPY --from=build /app/shared/dist shared/dist COPY --from=build /app/shared/dist shared/dist
COPY --from=build /app/server/dist server/dist COPY --from=build /app/server/dist server/dist
COPY --from=build /app/server/src/db/migrations server/src/db/migrations
ENV PORT=3001 ENV PORT=3001
EXPOSE 3001 EXPOSE 3001

View File

@ -0,0 +1,31 @@
services:
postgres:
image: postgres:16-alpine
container_name: anydrop-postgres-dev
environment:
POSTGRES_USER: anydrop
POSTGRES_PASSWORD: anydrop
POSTGRES_DB: anydrop
ports:
- "5433:5432"
volumes:
- anydrop_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U anydrop -d anydrop"]
interval: 5s
timeout: 5s
retries: 5
mailpit:
image: axllent/mailpit:latest
container_name: anydrop-mailpit-dev
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
environment:
MP_MAX_MESSAGES: 500
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes:
anydrop_pg_data:

12
server/drizzle.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://anydrop:anydrop@localhost:5433/anydrop",
},
strict: true,
verbose: true,
} satisfies Config;

View File

@ -8,18 +8,28 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@anydrop/shared": "*", "@anydrop/shared": "workspace:*",
"@hono/node-server": "^1.13.7",
"drizzle-orm": "^0.45.2",
"hono": "^4.6.14",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"nodemailer": "^6.9.16",
"postgres": "^3.4.5",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"ws": "^8.18.1" "ws": "^8.18.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@types/nodemailer": "^6.4.17",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10",
"tsx": "^4.19.4" "tsx": "^4.19.4"
} }
} }

16
server/src/db/client.ts Normal file
View File

@ -0,0 +1,16 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import * as schema from "./schema.js";
const connectionString =
process.env.DATABASE_URL ?? "postgres://anydrop:anydrop@localhost:5433/anydrop";
const client = postgres(connectionString, {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
});
export const db = drizzle(client, { schema });
export { client as sql };
export type DB = typeof db;

35
server/src/db/migrate.ts Normal file
View File

@ -0,0 +1,35 @@
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { existsSync } from "node:fs";
import { db, sql } from "./client.js";
function resolveMigrationsFolder(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.resolve(here, "migrations"),
path.resolve(here, "../../src/db/migrations"),
path.resolve(process.cwd(), "server/src/db/migrations"),
path.resolve(process.cwd(), "src/db/migrations"),
];
const found = candidates.find((p) => existsSync(p));
if (!found) {
throw new Error(
`[migrate] could not locate migrations folder; tried: ${candidates.join(", ")}`
);
}
return found;
}
async function main() {
const folder = resolveMigrationsFolder();
console.log(`[migrate] running migrations from ${folder}`);
await migrate(db, { migrationsFolder: folder });
console.log("[migrate] done");
await sql.end();
}
main().catch((err) => {
console.error("[migrate] failed:", err);
process.exit(1);
});

View File

@ -0,0 +1,48 @@
CREATE TABLE "magic_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"token_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"user_agent" text,
"ip_hash" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"device_id" text NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"avatar" text,
"linked_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"plan" text DEFAULT 'free' NOT NULL,
"stripe_customer_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_devices" ADD CONSTRAINT "user_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "magic_links_token_hash_unique" ON "magic_links" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "magic_links_email_idx" ON "magic_links" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX "sessions_token_hash_unique" ON "sessions" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_devices_user_device_unique" ON "user_devices" USING btree ("user_id","device_id");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");

View File

@ -0,0 +1,379 @@
{
"id": "a3d4d541-ef82-42cb-b317-1b27aca7bff6",
"prevId": "00000000-0000-0000-0000-000000000000",
"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.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

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1776644472089,
"tag": "0000_init",
"breakpoints": true
}
]
}

77
server/src/db/schema.ts Normal file
View File

@ -0,0 +1,77 @@
import { pgTable, text, timestamp, uuid, uniqueIndex, index } from "drizzle-orm/pg-core";
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull(),
plan: text("plan").notNull().default("free"),
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
emailUnique: uniqueIndex("users_email_unique").on(t.email),
}),
);
export const sessions = pgTable(
"sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("token_hash").notNull(),
userAgent: text("user_agent"),
ipHash: text("ip_hash"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
},
(t) => ({
tokenHashUnique: uniqueIndex("sessions_token_hash_unique").on(t.tokenHash),
userIdx: index("sessions_user_idx").on(t.userId),
}),
);
export const magicLinks = pgTable(
"magic_links",
{
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull(),
tokenHash: text("token_hash").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }),
},
(t) => ({
tokenHashUnique: uniqueIndex("magic_links_token_hash_unique").on(t.tokenHash),
emailIdx: index("magic_links_email_idx").on(t.email),
}),
);
export const userDevices = pgTable(
"user_devices",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
deviceId: text("device_id").notNull(),
name: text("name").notNull(),
type: text("type").notNull(),
avatar: text("avatar"),
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
userDeviceUnique: uniqueIndex("user_devices_user_device_unique").on(t.userId, t.deviceId),
}),
);
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;

32
server/src/http/app.ts Normal file
View File

@ -0,0 +1,32 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { authRoutes } from "./auth.js";
import { meRoutes } from "./me.js";
export function buildApp() {
const app = new Hono();
const corsOrigin = process.env.APP_URL ?? "http://localhost:5173";
app.use(
"/api/*",
cors({
origin: corsOrigin,
credentials: true,
allowHeaders: ["Content-Type"],
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
}),
);
app.get("/health", (c) => c.json({ status: "ok" }));
app.route("/api/auth", authRoutes);
app.route("/api", meRoutes);
app.notFound((c) => c.json({ error: "not_found" }, 404));
app.onError((err, c) => {
console.error("[http] error:", err);
return c.json({ error: "internal" }, 500);
});
return app;
}

101
server/src/http/auth.ts Normal file
View File

@ -0,0 +1,101 @@
import { Hono } from "hono";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createHash } from "node:crypto";
import { db } from "../db/client.js";
import { magicLinks, users } from "../db/schema.js";
import {
generateToken,
hashToken,
createSession,
setSessionCookie,
revokeSession,
} from "./session.js";
import { rateLimit, getClientIP } from "./middleware.js";
import { sendMagicLink } from "../mail/smtp.js";
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const authRoutes = new Hono();
authRoutes.post("/request-link", rateLimit(5), async (c) => {
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.body(null, 204); // anti-enumeration
}
const email = typeof (body as any)?.email === "string" ? (body as any).email.trim().toLowerCase() : "";
if (!EMAIL_RE.test(email) || email.length > 254) {
return c.body(null, 204);
}
const token = generateToken();
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
await db.insert(magicLinks).values({ email, tokenHash, expiresAt });
const appUrl = process.env.APP_URL ?? "http://localhost:5173";
const verifyUrl = `${appUrl}/api/auth/verify?token=${encodeURIComponent(token)}`;
try {
await sendMagicLink(email, verifyUrl);
} catch (err) {
console.error("[auth] failed to send magic link:", err);
// still return 204 (anti-enumeration), but log for monitoring
}
return c.body(null, 204);
});
authRoutes.get("/verify", async (c) => {
const token = c.req.query("token");
const appUrl = process.env.APP_URL ?? "http://localhost:5173";
if (!token) return c.redirect(`${appUrl}/settings?error=missing_token`);
const tokenHash = hashToken(token);
const rows = await db
.select()
.from(magicLinks)
.where(
and(
eq(magicLinks.tokenHash, tokenHash),
gt(magicLinks.expiresAt, new Date()),
isNull(magicLinks.usedAt),
),
)
.limit(1);
if (rows.length === 0) {
return c.redirect(`${appUrl}/settings?error=invalid_or_expired`);
}
const link = rows[0];
await db.update(magicLinks).set({ usedAt: new Date() }).where(eq(magicLinks.id, link.id));
let userRow = (
await db.select().from(users).where(eq(users.email, link.email)).limit(1)
)[0];
if (!userRow) {
userRow = (
await db
.insert(users)
.values({ email: link.email })
.returning()
)[0];
}
const ipHash = createHash("sha256").update(getClientIP(c)).digest("hex").slice(0, 16);
const userAgent = c.req.header("user-agent");
const sessionToken = await createSession(userRow.id, userAgent, ipHash);
await setSessionCookie(c, sessionToken);
return c.redirect(`${appUrl}/settings?signed_in=1`);
});
authRoutes.post("/logout", async (c) => {
await revokeSession(c);
return c.body(null, 204);
});

94
server/src/http/me.ts Normal file
View File

@ -0,0 +1,94 @@
import { Hono } from "hono";
import { and, eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { userDevices } from "../db/schema.js";
import { resolveSession } from "./session.js";
import { rateLimit } from "./middleware.js";
export const meRoutes = new Hono();
meRoutes.use("/me", rateLimit(30));
meRoutes.use("/devices", rateLimit(30));
meRoutes.use("/devices/*", rateLimit(30));
meRoutes.get("/me", async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: "unauthenticated" }, 401);
const devices = await db
.select()
.from(userDevices)
.where(eq(userDevices.userId, user.id))
.orderBy(userDevices.lastSeenAt);
return c.json({
user: { id: user.id, email: user.email, plan: user.plan, createdAt: user.createdAt },
devices: devices.map((d) => ({
id: d.id,
deviceId: d.deviceId,
name: d.name,
type: d.type,
avatar: d.avatar,
linkedAt: d.linkedAt,
lastSeenAt: d.lastSeenAt,
})),
});
});
const DEVICE_TYPES = new Set(["mobile", "tablet", "laptop", "desktop"]);
const MAX_AVATAR_SIZE = 100_000;
meRoutes.post("/devices", async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: "unauthenticated" }, 401);
let body: any;
try {
body = await c.req.json();
} catch {
return c.json({ error: "invalid_body" }, 400);
}
const deviceId = typeof body.deviceId === "string" ? body.deviceId.trim() : "";
const name = typeof body.name === "string" ? body.name.trim().slice(0, 60) : "";
const type = typeof body.type === "string" ? body.type : "";
const avatar = typeof body.avatar === "string" ? body.avatar : null;
if (!deviceId || deviceId.length > 128) return c.json({ error: "invalid_device_id" }, 400);
if (!name) return c.json({ error: "invalid_name" }, 400);
if (!DEVICE_TYPES.has(type)) return c.json({ error: "invalid_type" }, 400);
if (avatar && avatar.length > MAX_AVATAR_SIZE) return c.json({ error: "avatar_too_large" }, 400);
const existing = await db
.select()
.from(userDevices)
.where(and(eq(userDevices.userId, user.id), eq(userDevices.deviceId, deviceId)))
.limit(1);
if (existing.length > 0) {
const updated = await db
.update(userDevices)
.set({ name, type, avatar, lastSeenAt: new Date() })
.where(eq(userDevices.id, existing[0].id))
.returning();
return c.json(updated[0]);
}
const inserted = await db
.insert(userDevices)
.values({ userId: user.id, deviceId, name, type, avatar })
.returning();
return c.json(inserted[0], 201);
});
meRoutes.delete("/devices/:id", async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: "unauthenticated" }, 401);
const id = c.req.param("id");
await db
.delete(userDevices)
.where(and(eq(userDevices.id, id), eq(userDevices.userId, user.id)));
return c.body(null, 204);
});

View File

@ -0,0 +1,38 @@
import type { Context, MiddlewareHandler } from "hono";
export function getClientIP(c: Context): string {
const xff = c.req.header("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const realIp = c.req.header("x-real-ip");
if (realIp) return realIp.trim();
return "unknown";
}
type Bucket = { count: number; windowStart: number };
const buckets = new Map<string, Bucket>();
const WINDOW_MS = 60_000;
export function rateLimit(limit: number): MiddlewareHandler {
return async (c, next) => {
const ip = getClientIP(c);
const key = `${ip}:${c.req.path}`;
const now = Date.now();
let bucket = buckets.get(key);
if (!bucket || now - bucket.windowStart > WINDOW_MS) {
bucket = { count: 0, windowStart: now };
buckets.set(key, bucket);
}
bucket.count += 1;
if (bucket.count > limit) {
return c.json({ error: "rate_limit" }, 429);
}
await next();
};
}
setInterval(() => {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (now - bucket.windowStart > WINDOW_MS) buckets.delete(key);
}
}, WINDOW_MS).unref();

110
server/src/http/session.ts Normal file
View File

@ -0,0 +1,110 @@
import { createHash, randomBytes } from "node:crypto";
import { eq, and, gt } from "drizzle-orm";
import type { Context } from "hono";
import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
import { db } from "../db/client.js";
import { sessions, users, type User } from "../db/schema.js";
const SESSION_COOKIE = "anydrop_session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function getSessionSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 32) {
throw new Error("SESSION_SECRET env var must be set and at least 32 chars");
}
return secret;
}
function isSecureEnv(): boolean {
const url = process.env.APP_URL ?? "";
return url.startsWith("https://");
}
export function generateToken(): string {
return randomBytes(32).toString("base64url");
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function createSession(
userId: string,
userAgent: string | undefined,
ipHash: string | undefined,
): Promise<string> {
const token = generateToken();
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({
userId,
tokenHash,
userAgent: userAgent ?? null,
ipHash: ipHash ?? null,
expiresAt,
});
return token;
}
export async function setSessionCookie(c: Context, token: string): Promise<void> {
await setSignedCookie(c, SESSION_COOKIE, token, getSessionSecret(), {
httpOnly: true,
secure: isSecureEnv(),
sameSite: "Lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export function clearSessionCookie(c: Context): void {
deleteCookie(c, SESSION_COOKIE, {
path: "/",
secure: isSecureEnv(),
});
}
export async function resolveSession(c: Context): Promise<User | null> {
let token: string | false | undefined;
try {
token = await getSignedCookie(c, getSessionSecret(), SESSION_COOKIE);
} catch {
return null;
}
if (!token) return null;
const tokenHash = hashToken(token);
const rows = await db
.select({
user: users,
sessionId: sessions.id,
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.tokenHash, tokenHash), gt(sessions.expiresAt, new Date())))
.limit(1);
if (rows.length === 0) return null;
await db
.update(sessions)
.set({ lastUsedAt: new Date() })
.where(eq(sessions.id, rows[0].sessionId));
return rows[0].user;
}
export async function revokeSession(c: Context): Promise<void> {
let token: string | false | undefined;
try {
token = await getSignedCookie(c, getSessionSecret(), SESSION_COOKIE);
} catch {
token = undefined;
}
if (token) {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
clearSessionCookie(c);
}

View File

@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { networkInterfaces } from "node:os"; import { networkInterfaces } from "node:os";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { getRequestListener } from "@hono/node-server";
import { import {
type ClientMessage, type ClientMessage,
type ServerMessage, type ServerMessage,
@ -18,6 +19,7 @@ import {
getOfflineSubscribers, getOfflineSubscribers,
wakeDevice, wakeDevice,
} from "./push.js"; } from "./push.js";
import { buildApp } from "./http/app.js";
const PORT = parseInt(process.env.PORT || "3001", 10); const PORT = parseInt(process.env.PORT || "3001", 10);
const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
@ -149,17 +151,10 @@ setInterval(() => {
} }
}, 60_000); }, 60_000);
// ── HTTP server ── // ── HTTP server (Hono) ──
const httpServer = createServer((req, res) => { const honoApp = buildApp();
if (req.url === "/health") { const httpServer = createServer(getRequestListener(honoApp.fetch));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404);
res.end();
});
// ── WebSocket server ── // ── WebSocket server ──

42
server/src/mail/smtp.ts Normal file
View File

@ -0,0 +1,42 @@
import nodemailer, { type Transporter } from "nodemailer";
import { magicLinkEmail } from "./templates.js";
let cached: Transporter | null = null;
function getTransporter(): Transporter {
if (cached) return cached;
const host = process.env.SMTP_HOST ?? "localhost";
const port = parseInt(process.env.SMTP_PORT ?? "1025", 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
const secure = process.env.SMTP_SECURE === "true";
cached = nodemailer.createTransport({
host,
port,
secure,
auth: user && pass ? { user, pass } : undefined,
tls: {
// Self-signed cert support for in-cluster maddy — acceptable since traffic is cluster-internal.
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== "false",
},
});
return cached;
}
export async function sendMagicLink(toEmail: string, verifyUrl: string): Promise<void> {
const from = process.env.SMTP_FROM ?? "AnyDrop <noreply@anydrop.local>";
const { subject, text, html } = magicLinkEmail(verifyUrl);
const info = await getTransporter().sendMail({
from,
to: toEmail,
subject,
text,
html,
});
console.log(`[mail] magic link sent to ${toEmail} (messageId=${info.messageId})`);
}

View File

@ -0,0 +1,35 @@
export function magicLinkEmail(verifyUrl: string): { subject: string; text: string; html: string } {
const subject = "Your AnyDrop sign-in link";
const text = `Hi,
Click the link below to sign in to AnyDrop. It expires in 15 minutes and can only be used once.
${verifyUrl}
If you didn't request this, you can safely ignore this email.
AnyDrop
`;
const html = `<!doctype html>
<html lang="en">
<body style="margin:0;padding:32px;background:#0f172a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" style="max-width:480px;margin:0 auto;background:#1e293b;border-radius:16px;padding:32px;">
<tr><td>
<h1 style="margin:0 0 16px;font-size:22px;color:#f8fafc;">Sign in to AnyDrop</h1>
<p style="margin:0 0 24px;line-height:1.5;color:#cbd5e1;">Click the button below to sign in. The link expires in 15 minutes and can only be used once.</p>
<p style="margin:0 0 24px;">
<a href="${verifyUrl}" style="display:inline-block;background:#6366f1;color:#fff;text-decoration:none;padding:12px 20px;border-radius:10px;font-weight:600;">Sign in</a>
</p>
<p style="margin:0 0 8px;color:#94a3b8;font-size:13px;">Or copy this link into your browser:</p>
<p style="margin:0;word-break:break-all;color:#64748b;font-size:13px;">${verifyUrl}</p>
<hr style="border:none;border-top:1px solid #334155;margin:24px 0;">
<p style="margin:0;color:#64748b;font-size:12px;">If you didn't request this, you can safely ignore this email.</p>
</td></tr>
</table>
</body>
</html>`;
return { subject, text, html };
}

View File

@ -1 +1 @@
{"root":["./src/index.ts","./src/push.ts","./src/rooms.ts"],"version":"5.8.3"} {"root":["./src/index.ts","./src/push.ts","./src/rooms.ts","./src/db/client.ts","./src/db/migrate.ts","./src/db/schema.ts","./src/http/app.ts","./src/http/auth.ts","./src/http/me.ts","./src/http/middleware.ts","./src/http/session.ts","./src/mail/smtp.ts","./src/mail/templates.ts"],"version":"5.9.3"}

File diff suppressed because one or more lines are too long

View File

@ -1,19 +1,20 @@
# Build stage # Build stage
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
ARG VITE_WS_URL ARG VITE_WS_URL
COPY package.json package-lock.json ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY tsconfig.base.json ./
COPY shared/package.json shared/ COPY shared/package.json shared/
COPY server/package.json server/ COPY server/package.json server/
COPY web/package.json web/ COPY web/package.json web/
RUN npm ci RUN pnpm install --frozen-lockfile
COPY tsconfig.base.json ./
COPY shared/ shared/ COPY shared/ shared/
COPY web/ web/ COPY web/ web/
RUN npm run build -w shared && cd web && npx vite build RUN pnpm --filter @anydrop/shared run build && pnpm --filter @anydrop/web exec vite build
# Runtime stage # Runtime stage
FROM nginx:alpine FROM nginx:alpine

View File

@ -21,4 +21,14 @@ server {
proxy_read_timeout 86400; proxy_read_timeout 86400;
proxy_send_timeout 86400; proxy_send_timeout 86400;
} }
# HTTP API proxy to signaling server
location /api/ {
proxy_pass http://anydrop-server:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
} }

View File

@ -10,7 +10,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@anydrop/shared": "*", "@anydrop/shared": "workspace:*",
"events": "^3.3.0", "events": "^3.3.0",
"process": "^0.11.10", "process": "^0.11.10",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
@ -30,6 +30,10 @@
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "^6.3.3", "vite": "^6.3.3",
"vite-plugin-node-polyfills": "^0.26.0", "vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-pwa": "^1.0.0" "vite-plugin-pwa": "^1.0.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0",
"workbox-window": "^7.3.0"
} }
} }

View File

@ -3,6 +3,7 @@ import Home from "./pages/Home";
import JoinRoom from "./pages/JoinRoom"; import JoinRoom from "./pages/JoinRoom";
import Share from "./pages/Share"; import Share from "./pages/Share";
import Pair from "./pages/Pair"; import Pair from "./pages/Pair";
import Settings from "./pages/Settings";
export default function App() { export default function App() {
return ( return (
@ -10,6 +11,7 @@ export default function App() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/share" element={<Share />} /> <Route path="/share" element={<Share />} />
<Route path="/pair" element={<Pair />} /> <Route path="/pair" element={<Pair />} />
<Route path="/settings" element={<Settings />} />
<Route path="/:code" element={<JoinRoom />} /> <Route path="/:code" element={<JoinRoom />} />
</Routes> </Routes>
); );

69
web/src/lib/api.ts Normal file
View File

@ -0,0 +1,69 @@
export interface ApiUser {
id: string;
email: string;
plan: string;
createdAt: string;
}
export interface ApiDevice {
id: string;
deviceId: string;
name: string;
type: string;
avatar: string | null;
linkedAt: string;
lastSeenAt: string;
}
export interface MeResponse {
user: ApiUser;
devices: ApiDevice[];
}
async function call(path: string, init?: RequestInit): Promise<Response> {
return fetch(path, {
credentials: "include",
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
}
export async function requestMagicLink(email: string): Promise<void> {
await call("/api/auth/request-link", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function fetchMe(): Promise<MeResponse | null> {
const res = await call("/api/me");
if (res.status === 401) return null;
if (!res.ok) throw new Error(`fetchMe failed: ${res.status}`);
return (await res.json()) as MeResponse;
}
export async function registerDevice(input: {
deviceId: string;
name: string;
type: string;
avatar: string | null;
}): Promise<ApiDevice> {
const res = await call("/api/devices", {
method: "POST",
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`registerDevice failed: ${res.status}`);
return (await res.json()) as ApiDevice;
}
export async function unlinkDevice(id: string): Promise<void> {
const res = await call(`/api/devices/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) throw new Error(`unlinkDevice failed: ${res.status}`);
}
export async function signOut(): Promise<void> {
await call("/api/auth/logout", { method: "POST" });
}

View File

@ -160,6 +160,9 @@ function HomeConnected() {
{/* Footer */} {/* Footer */}
<footer className="text-center text-xs text-slate-600 mt-12"> <footer className="text-center text-xs text-slate-600 mt-12">
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p> <p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
<a href="/settings" className="inline-block mt-2 text-slate-700 hover:text-slate-500 transition-colors">
Account
</a>
</footer> </footer>
</div> </div>

189
web/src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,189 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useAuthStore } from "../stores/useAuthStore";
import { useProfileStore } from "../stores/useProfileStore";
import { registerDevice, requestMagicLink, unlinkDevice } from "../lib/api";
export default function Settings() {
const user = useAuthStore((s) => s.user);
const devices = useAuthStore((s) => s.devices);
const loaded = useAuthStore((s) => s.loaded);
const loading = useAuthStore((s) => s.loading);
const loadUser = useAuthStore((s) => s.loadUser);
const signOut = useAuthStore((s) => s.signOut);
const setDevices = useAuthStore((s) => s.setDevices);
const profile = useProfileStore();
const [searchParams] = useSearchParams();
const signedIn = searchParams.get("signed_in") === "1";
const error = searchParams.get("error");
useEffect(() => {
loadUser();
}, [loadUser]);
useEffect(() => {
if (!user || !profile.isSetUp) return;
const alreadyLinked = devices.some((d) => d.deviceId === profile.deviceId);
if (alreadyLinked) return;
registerDevice({
deviceId: profile.deviceId,
name: profile.deviceName,
type: profile.deviceType,
avatar: profile.avatar,
})
.then((d) => setDevices([...devices, d]))
.catch(() => {});
}, [user, profile.isSetUp, profile.deviceId, profile.deviceName, profile.deviceType, profile.avatar, devices, setDevices]);
if (!loaded || loading) {
return <SettingsShell><p className="text-slate-400">Loading</p></SettingsShell>;
}
if (!user) {
return <SettingsShell><SignInForm initialError={error} /></SettingsShell>;
}
return (
<SettingsShell>
{signedIn && (
<div className="mb-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30 px-4 py-3 text-sm text-emerald-200">
Signed in successfully.
</div>
)}
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Account</h2>
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-4">
<div className="text-sm text-slate-400">Email</div>
<div className="text-slate-100">{user.email}</div>
<div className="mt-3 text-xs text-slate-500">Plan: <span className="text-slate-300">{user.plan}</span></div>
</div>
</section>
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Devices</h2>
<div className="rounded-xl bg-slate-900/60 border border-slate-800 divide-y divide-slate-800">
{devices.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-500">No devices linked yet.</div>
)}
{devices.map((d) => {
const isCurrent = d.deviceId === profile.deviceId;
return (
<div key={d.id} className="flex items-center justify-between px-4 py-3">
<div>
<div className="text-slate-100 flex items-center gap-2">
{d.name}
{isCurrent && (
<span className="text-[10px] uppercase tracking-wider bg-indigo-500/20 text-indigo-300 px-1.5 py-0.5 rounded">
this device
</span>
)}
</div>
<div className="text-xs text-slate-500">{d.type} · linked {new Date(d.linkedAt).toLocaleDateString()}</div>
</div>
{!isCurrent && (
<button
onClick={async () => {
await unlinkDevice(d.id);
setDevices(devices.filter((x) => x.id !== d.id));
}}
className="text-xs text-slate-400 hover:text-rose-400"
>
Remove
</button>
)}
</div>
);
})}
</div>
</section>
<button
onClick={() => signOut()}
className="text-sm text-slate-400 hover:text-rose-400"
>
Sign out
</button>
</SettingsShell>
);
}
function SignInForm({ initialError }: { initialError: string | null }) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
if (submitted) {
return (
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6 text-center">
<div className="text-xl mb-2">📬</div>
<h2 className="text-lg text-slate-100 mb-2">Check your inbox</h2>
<p className="text-sm text-slate-400">
If an account exists for <span className="text-slate-200">{email}</span>, a sign-in link is on its way. It expires in 15 minutes.
</p>
</div>
);
}
return (
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6">
<h2 className="text-lg text-slate-100 mb-2">Sign in</h2>
<p className="text-sm text-slate-400 mb-4">
Optional. Signing in lets you sync your profile and devices across browsers. Your transfers stay peer-to-peer.
</p>
{initialError && (
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 px-3 py-2 text-xs text-rose-200">
{initialError === "invalid_or_expired"
? "That link has expired or already been used."
: "Something went wrong. Please try again."}
</div>
)}
<form
onSubmit={async (e) => {
e.preventDefault();
if (submitting) return;
setSubmitting(true);
try {
await requestMagicLink(email);
} finally {
setSubmitted(true);
setSubmitting(false);
}
}}
className="flex flex-col gap-3"
>
<input
type="email"
required
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={submitting}
className="bg-indigo-500 hover:bg-indigo-400 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-2"
>
{submitting ? "Sending…" : "Send sign-in link"}
</button>
</form>
</div>
);
}
function SettingsShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 px-4 py-10">
<div className="max-w-lg mx-auto">
<div className="mb-6 flex items-center justify-between">
<Link to="/" className="text-sm text-slate-400 hover:text-slate-200"> Back</Link>
<h1 className="text-xl font-semibold">Account</h1>
<div className="w-10" />
</div>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { create } from "zustand";
import type { ApiDevice, ApiUser } from "../lib/api";
import { fetchMe, signOut as apiSignOut } from "../lib/api";
interface AuthState {
user: ApiUser | null;
devices: ApiDevice[];
loaded: boolean;
loading: boolean;
loadUser: () => Promise<void>;
setDevices: (devices: ApiDevice[]) => void;
signOut: () => Promise<void>;
}
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
devices: [],
loaded: false,
loading: false,
loadUser: async () => {
set({ loading: true });
try {
const me = await fetchMe();
if (me) {
set({ user: me.user, devices: me.devices, loaded: true, loading: false });
} else {
set({ user: null, devices: [], loaded: true, loading: false });
}
} catch {
set({ user: null, devices: [], loaded: true, loading: false });
}
},
setDevices: (devices) => set({ devices }),
signOut: async () => {
try {
await apiSignOut();
} finally {
set({ user: null, devices: [] });
}
},
}));

View File

@ -83,5 +83,11 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
host: true, host: true,
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: false,
},
},
}, },
}); });