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 }} \
|
--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
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:
|
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
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",
|
"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
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
|
# 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
|
||||||
|
|||||||
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",
|
"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
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 { 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
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
|
# 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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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 */}
|
||||||
<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
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: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: true,
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3001",
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user