feat: stealth accounts + data layer (Phase 1)
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 1m47s
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 1m47s
This commit is contained in:
parent
acda1a8bb8
commit
2913618ee6
37
.claude/settings.local.json
Normal file
37
.claude/settings.local.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -68,7 +68,10 @@ jobs:
|
||||
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
|
||||
--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/web.yml
|
||||
|
||||
|
||||
201
k8s/maddy.yml
Normal file
201
k8s/maddy.yml
Normal 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
84
k8s/postgres.yml
Normal 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
41
k8s/secrets.example.yml
Normal 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
|
||||
@ -6,9 +6,15 @@ metadata:
|
||||
data:
|
||||
PORT: "3001"
|
||||
BASE_URL: "https://anydrop.arthurbarre.fr"
|
||||
APP_URL: "https://anydrop.arthurbarre.fr"
|
||||
VAPID_PUBLIC_KEY: "BCta0SNLmjBFfizMInnBhEQvVZlMbbaM-qw1a-p3JeQykCyy00GRGkDAKMDA5nv5UfokwJ30HRGoA6buJjWwKcE"
|
||||
VAPID_PRIVATE_KEY: "gbmrcm9Tuz4JgoHophO-jUbam8rV9YgjImYcWvoE0w0"
|
||||
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
|
||||
@ -26,6 +32,15 @@ spec:
|
||||
labels:
|
||||
app: anydrop-server
|
||||
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:
|
||||
- name: anydrop-server
|
||||
image: git.arthurbarre.fr/ordinarthur/anydrop-server:latest
|
||||
@ -34,6 +49,8 @@ spec:
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: anydrop-server-config
|
||||
- secretRef:
|
||||
name: anydrop-app-secrets
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
@ -48,11 +65,11 @@ spec:
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
memory: "96Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "200m"
|
||||
memory: "192Mi"
|
||||
cpu: "300m"
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
|
||||
|
||||
9514
package-lock.json
generated
9514
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -3,17 +3,13 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Universal file & text sharing — peer-to-peer, no account required",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,green \"cd server && tsx watch src/index.ts\" \"cd web && vite\"",
|
||||
"dev:web": "cd web && vite",
|
||||
"dev:server": "cd server && tsx watch src/index.ts",
|
||||
"build": "tsc -b shared && tsc -b server && cd web && vite build",
|
||||
"typecheck": "tsc -b shared && tsc -b server && cd web && tsc --noEmit"
|
||||
"dev": "concurrently -n server,web -c blue,green \"pnpm --filter @anydrop/server dev\" \"pnpm --filter @anydrop/web dev\"",
|
||||
"dev:web": "pnpm --filter @anydrop/web dev",
|
||||
"dev:server": "pnpm --filter @anydrop/server dev",
|
||||
"dev:services": "docker compose -f server/docker-compose.dev.yml up -d",
|
||||
"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": {
|
||||
"concurrently": "^9.1.2",
|
||||
|
||||
6489
pnpm-lock.yaml
generated
Normal file
6489
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- shared
|
||||
- server
|
||||
- web
|
||||
@ -1,30 +1,33 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
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 server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN npm ci
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.base.json ./
|
||||
COPY shared/ shared/
|
||||
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
|
||||
FROM node:20-alpine
|
||||
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 server/package.json server/
|
||||
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/server/dist server/dist
|
||||
COPY --from=build /app/server/src/db/migrations server/src/db/migrations
|
||||
|
||||
ENV PORT=3001
|
||||
EXPOSE 3001
|
||||
|
||||
31
server/docker-compose.dev.yml
Normal file
31
server/docker-compose.dev.yml
Normal 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
12
server/drizzle.config.ts
Normal 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;
|
||||
@ -8,18 +8,28 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"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": {
|
||||
"@anydrop/shared": "*",
|
||||
"@anydrop/shared": "workspace:*",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.6.14",
|
||||
"nanoid": "^5.1.5",
|
||||
"nodemailer": "^6.9.16",
|
||||
"postgres": "^3.4.5",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.19.4"
|
||||
}
|
||||
}
|
||||
|
||||
16
server/src/db/client.ts
Normal file
16
server/src/db/client.ts
Normal 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
35
server/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
48
server/src/db/migrations/0000_init.sql
Normal file
48
server/src/db/migrations/0000_init.sql
Normal 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");
|
||||
379
server/src/db/migrations/meta/0000_snapshot.json
Normal file
379
server/src/db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
server/src/db/migrations/meta/_journal.json
Normal file
13
server/src/db/migrations/meta/_journal.json
Normal 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
77
server/src/db/schema.ts
Normal 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
32
server/src/http/app.ts
Normal 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
101
server/src/http/auth.ts
Normal 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
94
server/src/http/me.ts
Normal 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);
|
||||
});
|
||||
38
server/src/http/middleware.ts
Normal file
38
server/src/http/middleware.ts
Normal 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
110
server/src/http/session.ts
Normal 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);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getRequestListener } from "@hono/node-server";
|
||||
import {
|
||||
type ClientMessage,
|
||||
type ServerMessage,
|
||||
@ -18,6 +19,7 @@ import {
|
||||
getOfflineSubscribers,
|
||||
wakeDevice,
|
||||
} from "./push.js";
|
||||
import { buildApp } from "./http/app.js";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
|
||||
@ -149,17 +151,10 @@ setInterval(() => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// ── HTTP server ──
|
||||
// ── HTTP server (Hono) ──
|
||||
|
||||
const httpServer = createServer((req, res) => {
|
||||
if (req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
const honoApp = buildApp();
|
||||
const httpServer = createServer(getRequestListener(honoApp.fetch));
|
||||
|
||||
// ── WebSocket server ──
|
||||
|
||||
|
||||
42
server/src/mail/smtp.ts
Normal file
42
server/src/mail/smtp.ts
Normal 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})`);
|
||||
}
|
||||
35
server/src/mail/templates.ts
Normal file
35
server/src/mail/templates.ts
Normal 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 };
|
||||
}
|
||||
@ -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
@ -1,19 +1,20 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
|
||||
|
||||
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 server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN npm ci
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.base.json ./
|
||||
COPY shared/ shared/
|
||||
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
|
||||
FROM nginx:alpine
|
||||
|
||||
@ -21,4 +21,14 @@ server {
|
||||
proxy_read_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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anydrop/shared": "*",
|
||||
"@anydrop/shared": "workspace:*",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@ -30,6 +30,10 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.3.3",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import Home from "./pages/Home";
|
||||
import JoinRoom from "./pages/JoinRoom";
|
||||
import Share from "./pages/Share";
|
||||
import Pair from "./pages/Pair";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -10,6 +11,7 @@ export default function App() {
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/share" element={<Share />} />
|
||||
<Route path="/pair" element={<Pair />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/:code" element={<JoinRoom />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
69
web/src/lib/api.ts
Normal file
69
web/src/lib/api.ts
Normal 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" });
|
||||
}
|
||||
@ -160,6 +160,9 @@ function HomeConnected() {
|
||||
{/* Footer */}
|
||||
<footer className="text-center text-xs text-slate-600 mt-12">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
189
web/src/pages/Settings.tsx
Normal file
189
web/src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
web/src/stores/useAuthStore.ts
Normal file
45
web/src/stores/useAuthStore.ts
Normal 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: [] });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@ -83,5 +83,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user