feat: initial commit with full deployment setup
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 8s

Includes React PWA frontend, WebSocket signaling server, shared types,
K8s manifests, Gitea CI/CD workflow, nginx config, and Dockerfiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-14 10:30:45 +02:00
commit 9d6e4da4ae
52 changed files with 12712 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
.git
dist
docs
*.md
.gitea
k8s

View File

@ -0,0 +1,83 @@
name: Build & Deploy
on:
push:
branches: [main]
env:
REGISTRY: git.arthurbarre.fr
IMAGE_SERVER: ordinarthur/anydrop-server
IMAGE_WEB: ordinarthur/anydrop-web
NAMESPACE: anydrop
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ordinarthur
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push server
uses: docker/build-push-action@v5
with:
context: .
file: server/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_SERVER }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_SERVER }}:${{ github.sha }}
- name: Build and push web
uses: docker/build-push-action@v5
with:
context: .
file: web/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:${{ github.sha }}
build-args: |
VITE_WS_URL=wss://anydrop.arthurbarre.fr/ws
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Deploy to K3s
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
# Create namespace if needed
kubectl apply -f k8s/namespace.yml
# Create registry secret if needed
kubectl -n $NAMESPACE create secret docker-registry gitea-registry \
--docker-server=$REGISTRY \
--docker-username=ordinarthur \
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
# Apply manifests
kubectl apply -f k8s/server.yml
kubectl apply -f k8s/web.yml
# Force rollout with new images
kubectl -n $NAMESPACE set image deployment/anydrop-server \
anydrop-server=$REGISTRY/$IMAGE_SERVER:${{ github.sha }}
kubectl -n $NAMESPACE set image deployment/anydrop-web \
anydrop-web=$REGISTRY/$IMAGE_WEB:${{ github.sha }}
# Wait for rollout
kubectl -n $NAMESPACE rollout status deployment/anydrop-server --timeout=120s
kubectl -n $NAMESPACE rollout status deployment/anydrop-web --timeout=120s

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
.DS_Store

124
CLAUDE.md Normal file
View File

@ -0,0 +1,124 @@
# AnyDrop
> Partage de fichiers et de texte instantané, multi-OS, peer-to-peer, sans compte.
**Domaine de production :** `anydrop.arthurbarre.fr`
**Auteur :** Arthur Barré
**Status :** V1 — Web app (en cours)
---
## Vision produit
AnyDrop est un "AirDrop universel" : il permet de partager fichiers, photos et texte instantanément entre **tous** les appareils (iOS, Android, macOS, Windows, Linux), quel que soit leur système d'exploitation, sans compte obligatoire et sans installation forcée.
Deux modes de partage :
1. **Mode réseau local** — Deux appareils sur le même Wi-Fi se voient automatiquement (détection par IP publique partagée).
2. **Mode lien public** — L'utilisateur génère une URL courte `anydrop.arthurbarre.fr/x7k` + QR code, partageable à n'importe qui, n'importe où (transfert P2P à travers internet via STUN/TURN).
Dans les deux cas, le transfert est **pair-à-pair** (WebRTC DataChannel), **chiffré** (DTLS), et **ne transite pas par le serveur**.
---
## Architecture technique
### Stack V1
**Front web**
- React 18 + Vite + TypeScript
- TailwindCSS
- Zustand (state management)
- `simple-peer` (abstraction WebRTC)
- `qrcode.react` (génération QR code)
- PWA (installable sur mobile/desktop)
**Back signaling**
- Node.js + TypeScript
- `ws` (WebSocket)
- Rooms en mémoire (pas de DB en V1)
- Génération de codes courts (3-4 caractères, alphabet sans ambiguïté)
**Infra**
- Signaling : Railway / Fly.io
- Front : Vercel / Netlify
- STUN : `stun.l.google.com:19302` (gratuit)
- TURN : Open Relay (gratuit) → coturn auto-hébergé plus tard
### Pourquoi WebRTC ?
- Transferts **P2P** (rapides sur réseau local, 50-500 Mo/s)
- **Chiffrement natif obligatoire** (DTLS)
- Fonctionne dans tous les navigateurs modernes
- Pas de coût bande passante serveur pour les données
### Rôle du serveur de signaling
Le serveur **ne voit jamais les fichiers**. Il sert uniquement à :
1. Regrouper les pairs par "room" (IP publique ou code court)
2. Relayer les SDP offers/answers et ICE candidates entre les pairs
3. Notifier les connexions/déconnexions
Une fois la connexion WebRTC établie, le serveur est transparent.
---
## Structure du repo
```
anydrop/
├── CLAUDE.md # Ce fichier
├── docs/ # Documentation technique détaillée
│ ├── architecture.md
│ ├── webrtc-flow.md
│ ├── signaling-protocol.md
│ └── roadmap.md
├── web/ # React PWA (front utilisateur)
├── server/ # Node signaling WebSocket
├── shared/ # Types TypeScript partagés front/back
└── package.json # Monorepo (npm workspaces)
```
---
## Roadmap (résumé)
- **Phase 1 — Web PWA** *(en cours)* : détection LAN, lien public + QR, transferts fichiers/texte
- **Phase 2 — Extensions** : Chrome + Firefox (menu contextuel "Envoyer via AnyDrop")
- **Phase 3 — Desktop** : Tauri (macOS + Windows + Linux, une base de code)
- **Phase 4 — Mobile** : iOS + Android (React Native ou Flutter, Share Sheet natif)
- **Phase 5 — Avancé** : chiffrement applicatif additionnel, transferts de dossiers, historique
Détails dans `docs/roadmap.md`.
---
## Conventions
- **Langue du code** : anglais (variables, commentaires, commits)
- **Langue de la doc utilisateur** : français (site + UI)
- **Commits** : style conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`…)
- **Pas de compte / pas de tracking** : valeur produit fondamentale, ne pas l'abandonner pour des "features"
- **Pas de stockage côté serveur** des contenus échangés (les rooms sont en RAM, volatiles)
---
## Commandes principales (à venir une fois scaffoldé)
```bash
npm install # installe le monorepo
npm run dev # démarre front + signaling en parallèle
npm run dev:web # uniquement le front (Vite)
npm run dev:server # uniquement le signaling
npm run build # build production
npm run typecheck # vérification TS globale
```
---
## Principes de développement
1. **Privacy by design** — Pas de logs de contenus, pas d'analytics intrusifs, codes de rooms éphémères.
2. **Zero-friction** — Ouvrir l'URL doit suffire. Pas de compte, pas d'email.
3. **Progressive enhancement** — Le web marche partout ; les apps natives ajoutent du confort, pas des features bloquantes.
4. **Un seul domaine, plusieurs clients** — Tous les clients (web, extensions, desktop, mobile) parlent au même signaling.

87
docs/architecture.md Normal file
View File

@ -0,0 +1,87 @@
# Architecture AnyDrop
## Vue d'ensemble
AnyDrop repose sur trois briques :
1. **Client** (web, extension, desktop, mobile) — interface utilisateur + moteur WebRTC
2. **Serveur de signaling** — met en relation les pairs, ne voit jamais les fichiers
3. **Serveurs STUN/TURN** — aident à établir la connexion P2P à travers les NAT/firewalls
```
┌──────────┐ WebSocket ┌────────────────┐ WebSocket ┌──────────┐
│ Client A │ ────────────────────▶ │ Signaling │ ◀──────────────────── │ Client B │
└──────────┘ │ Server │ └──────────┘
│ └────────────────┘ │
│ │
│ ═══════ WebRTC DataChannel (P2P chiffré DTLS) ═══════ │
└─────────────────────────────────────────────────────────────────────────────┘
(les fichiers transitent uniquement ici)
```
## Le serveur de signaling
### Responsabilités
- Accepter les connexions WebSocket entrantes
- Attribuer un `peerId` unique et un `displayName` (nom animal aléatoire) à chaque client
- Placer le client dans la ou les rooms appropriées :
- Room **LAN** : hash de l'IP publique de la connexion
- Room **publique** : code court fourni dans l'URL (ex: `/x7k`)
- Relayer les messages de signaling (`offer`, `answer`, `ice-candidate`) entre pairs d'une même room
- Notifier `peer-joined` / `peer-left`
- Expirer les rooms publiques inactives (10 min)
### Ce que le serveur ne fait PAS
- ❌ Ne stocke aucun fichier ni contenu échangé
- ❌ Ne log pas les IPs dans des fichiers persistants
- ❌ N'a pas de base de données
- ❌ N'a pas d'authentification
## Les clients
Tous les clients (web, extensions, desktop, mobile) implémentent le **même protocole de signaling** et la même logique WebRTC. Le code `shared/` exporte les types de messages communs pour garantir la cohérence.
### Couche abstraction P2P
On utilise `simple-peer` pour masquer la complexité de l'API WebRTC native :
```ts
const peer = new SimplePeer({ initiator: true, trickle: true });
peer.on('signal', data => sendViaWebSocket(data)); // envoyer offer/ICE
peer.on('connect', () => sendFile(file)); // connexion établie
peer.on('data', chunk => reassemble(chunk)); // chunks reçus
```
### Découpage des fichiers
- Chunk size : **16 Ko** (compromis débit/mémoire)
- Header JSON initial : `{ name, size, mime, id }`
- Puis envoi séquentiel avec backpressure (`peer._channel.bufferedAmount < 1MB`)
- Fin de fichier : chunk spécial `{ eof: true, id }`
- Récepteur : accumule dans un `Blob[]` puis `new Blob(chunks, { type })`
## STUN et TURN
### STUN (Session Traversal Utilities for NAT)
Gratuit, quasi-gratuit en bande passante (quelques paquets). Sert uniquement à ce que le client découvre sa propre IP publique + port NAT, pour l'inclure dans les ICE candidates.
Serveurs utilisés : `stun.l.google.com:19302`, `stun1.l.google.com:19302`.
### TURN (Traversal Using Relays around NAT)
Fallback quand le P2P direct échoue (~10-20% des cas, surtout en entreprise / 4G). Le trafic est alors **relayé** par le serveur TURN → coût bande passante réel.
- V1 : Open Relay (gratuit, 500 Go/mois) — suffisant pour un lancement
- Plus tard : coturn auto-hébergé sur un VPS
## Déploiement cible
| Composant | Hébergement | URL |
|----------------|----------------------|-----------------------------------|
| Front web (PWA)| Vercel / Netlify | `anydrop.arthurbarre.fr` |
| Signaling | Railway / Fly.io | `ws.anydrop.arthurbarre.fr` |
| STUN | Google (gratuit) | `stun.l.google.com:19302` |
| TURN | Open Relay → coturn | `turn.anydrop.arthurbarre.fr` |

95
docs/roadmap.md Normal file
View File

@ -0,0 +1,95 @@
# Roadmap AnyDrop
## Phase 1 — Web PWA (V1, en cours)
**Objectif :** Produit utilisable depuis n'importe quel navigateur moderne, sur `anydrop.arthurbarre.fr`.
### Features
- [ ] Détection auto des pairs sur le même réseau (room LAN par IP publique)
- [ ] Envoi de fichiers (drag & drop, sélecteur)
- [ ] Envoi de plusieurs fichiers en une fois
- [ ] Envoi de texte / liens (modal)
- [ ] Aperçu image avant envoi
- [ ] Popup accepter/refuser côté récepteur
- [ ] Barre de progression par transfert
- [ ] Nom d'appareil animal auto-généré (pas de compte)
- [ ] Mode lien public `/xxx` avec code à 3 caractères
- [ ] Génération QR code pour lien public
- [ ] Expiration des rooms publiques (10 min d'inactivité)
- [ ] PWA (manifest + service worker, installable)
- [ ] Responsive mobile + desktop
- [ ] Dark mode
### Stack
React + Vite + TS + Tailwind + Zustand + simple-peer + qrcode.react
Node + ws + TypeScript
### Livrable
Site fonctionnel déployé sur `anydrop.arthurbarre.fr` + signaling sur `ws.anydrop.arthurbarre.fr`.
---
## Phase 2 — Extensions navigateur
**Objectif :** Partage en 1 clic depuis n'importe quelle page.
- Extension Chrome (Manifest V3)
- Extension Firefox (WebExtensions)
- Menu contextuel : "Envoyer via AnyDrop" sur page, image, lien, texte sélectionné
- Popup réutilisant la liste des pairs
- Réutilisation maximale du code `web/`
---
## Phase 3 — Apps desktop (Tauri)
**Objectif :** Intégration OS native sans payer le prix d'Electron.
- Tauri (Rust + webview système)
- Targets : macOS (Intel + Apple Silicon), Windows 10+, Linux (AppImage + .deb)
- Features natives :
- Menu "Envoyer à AnyDrop" via Share Sheet (macOS) / Context Menu (Windows)
- Tray icon avec liste des pairs
- Dossier de réception configurable
- Notifications natives
- Démarrage auto (optionnel)
---
## Phase 4 — Apps mobiles
**Objectif :** Expérience native, intégrée au Share Sheet système.
- React Native (choix préféré pour réutiliser l'écosystème JS) ou Flutter
- iOS : Share Extension (partager depuis Photos, Safari, etc.)
- Android : Share Intent (partager depuis n'importe quelle app)
- Scan QR code intégré (caméra)
- Historique local (chiffré)
### Note Windows Phone
Windows Phone a été arrêté par Microsoft en 2019. Les rares appareils existants utiliseront la **version web** via Edge mobile.
---
## Phase 5 — Fonctionnalités avancées
- Transfert de dossiers entiers (zip streaming)
- Chiffrement applicatif additionnel (SafetyNumber vérifiable)
- Reprise de transfert après coupure
- Historique chiffré local des transferts
- Favoris d'appareils (trust on first use)
- Multi-pair simultané (envoyer à plusieurs pairs en parallèle)
- Statistiques agrégées anonymes (opt-in)
---
## Hors-scope (pour l'instant)
- Comptes utilisateurs
- Stockage cloud des fichiers
- Messagerie persistante
- Collaboration temps réel sur documents
- Monétisation
Ces choix sont volontaires : AnyDrop doit rester **simple, rapide, privé**.

123
docs/signaling-protocol.md Normal file
View File

@ -0,0 +1,123 @@
# Protocole de signaling AnyDrop
Protocole simple en JSON via WebSocket. Endpoint : `wss://ws.anydrop.arthurbarre.fr`.
Tous les messages ont un champ `type`. Les types côté client et côté serveur sont définis dans `shared/protocol.ts`.
## Messages client → serveur
### `hello`
Envoyé immédiatement après connexion. Si `joinCode` est présent, le client rejoint la room publique correspondante. Sinon il est placé dans la room LAN basée sur son IP publique.
```json
{
"type": "hello",
"joinCode": "x7k" // optionnel
}
```
### `create-public-room`
Demande la création d'une room publique avec un code court.
```json
{ "type": "create-public-room" }
```
### `signal`
Relaie un message WebRTC (offer, answer, ICE candidate) vers un autre pair de la même room.
```json
{
"type": "signal",
"to": "b2",
"data": { /* SDP ou ICE */ }
}
```
### `leave`
Le client annonce qu'il quitte (optionnel, sinon détecté par fermeture WebSocket).
```json
{ "type": "leave" }
```
## Messages serveur → client
### `welcome`
Réponse au `hello`. Fournit l'identité du client.
```json
{
"type": "welcome",
"peerId": "a1",
"displayName": "Renard Bleu",
"roomId": "lan:abc123",
"peers": [
{ "peerId": "b2", "displayName": "Tigre Rouge" }
]
}
```
### `public-room-created`
Réponse à `create-public-room`.
```json
{
"type": "public-room-created",
"code": "x7k",
"url": "https://anydrop.arthurbarre.fr/x7k",
"expiresAt": "2026-04-14T18:30:00Z"
}
```
### `peer-joined`
Un nouveau pair rejoint la room.
```json
{
"type": "peer-joined",
"peerId": "c3",
"displayName": "Ours Vert"
}
```
### `peer-left`
Un pair a quitté.
```json
{
"type": "peer-left",
"peerId": "c3"
}
```
### `signal`
Relai d'un message WebRTC reçu d'un autre pair.
```json
{
"type": "signal",
"from": "a1",
"data": { /* SDP ou ICE */ }
}
```
### `error`
Erreur générique.
```json
{
"type": "error",
"code": "room-not-found" | "room-expired" | "rate-limit",
"message": "..."
}
```
## Règles serveur
- Un client peut appartenir à **1 room LAN + 0 ou 1 room publique** simultanément.
- Un message `signal` n'est relayé que si `to` est dans au moins une des rooms du sender.
- Une room publique expire après **10 minutes sans nouveau message**.
- Le code court est généré dans l'alphabet `abcdefghjkmnpqrstuvwxyz23456789` (exclut `iloLIO01` pour lisibilité).
- Longueur par défaut : **3 caractères** ; si collision, essayer 5 fois puis passer à **4 caractères**.
- Rate limit : 10 connexions/minute par IP, 100 messages/minute par connexion.

146
docs/webrtc-flow.md Normal file
View File

@ -0,0 +1,146 @@
# Flux WebRTC détaillé
Ce document décrit pas-à-pas comment deux clients AnyDrop établissent une connexion P2P.
## Acteurs
- **Alice** — émetteur (veut envoyer un fichier)
- **Bob** — récepteur
- **Signaling** — serveur WebSocket AnyDrop
- **STUN**`stun.l.google.com:19302`
- **TURN** — fallback si nécessaire
## Scénario 1 : Alice et Bob sur le même Wi-Fi
### 1. Connexion au signaling
```
Alice ──WS──▶ Signaling : { type: "hello" }
Signaling ──▶ Alice : { type: "welcome", peerId: "a1", displayName: "Renard Bleu" }
Bob ──WS──▶ Signaling : { type: "hello" }
Signaling ──▶ Bob : { type: "welcome", peerId: "b2", displayName: "Tigre Rouge" }
```
Le serveur voit que les deux connexions proviennent de la **même IP publique** (la box d'Arthur) → il les place dans la room LAN `hash(IP)`.
### 2. Annonce mutuelle
```
Signaling ──▶ Alice : { type: "peer-joined", peerId: "b2", displayName: "Tigre Rouge" }
Signaling ──▶ Bob : { type: "peer-joined", peerId: "a1", displayName: "Renard Bleu" }
```
Chaque client affiche un pair cliquable dans l'UI.
### 3. Alice déclenche l'envoi
Alice drop un fichier sur "Tigre Rouge". Son client crée une `RTCPeerConnection` en mode initiator :
```ts
const pc = new SimplePeer({ initiator: true, trickle: true });
pc.addStream(file); // simplifié
```
### 4. Collecte des ICE candidates
Le navigateur d'Alice :
- Regarde ses interfaces réseau → trouve `192.168.1.42` → candidate **host**
- Interroge STUN → apprend son IP publique `82.x.x.x:54321` → candidate **srflx** (server reflexive)
- Si TURN configuré → réserve une allocation → candidate **relay**
### 5. Échange SDP via signaling
```
Alice ──▶ Signaling : { type: "signal", to: "b2", data: <SDP offer + ICE host> }
Signaling ──▶ Bob : { type: "signal", from: "a1", data: <SDP offer + ICE host> }
```
Bob reçoit l'offer, crée sa propre `RTCPeerConnection` (non-initiator) :
```
Bob ──▶ Signaling : { type: "signal", to: "a1", data: <SDP answer + ICE host> }
Signaling ──▶ Alice : { type: "signal", from: "b2", data: <SDP answer + ICE host> }
```
Les nouveaux ICE candidates découverts après coup sont envoyés par la même route (trickle ICE).
### 6. Connectivity checks
WebRTC teste toutes les paires de candidates (A.host × B.host, A.srflx × B.host, etc.) avec des paquets STUN binding request. Sur même LAN, **A.host ↔ B.host répond en premier** → ce chemin est sélectionné.
Durée typique : **500 ms à 2 s**.
### 7. DataChannel ouvert
```ts
peer.on('connect', () => {
peer.send(JSON.stringify({ name: "photo.jpg", size: 2458112, mime: "image/jpeg", id: "f1" }));
streamFileInChunks(file);
});
```
Le transfert se fait **directement via le Wi-Fi local** — débit proche du lien physique (WiFi 6 : 500 Mo/s possibles).
### 8. Fin
Chunk final `{ eof: true, id: "f1" }`, les deux pairs ferment la RTCPeerConnection ou la gardent ouverte pour un prochain envoi.
---
## Scénario 2 : Alice partage un lien public à Charlie (autre réseau)
### 1. Alice génère un code
Alice clique sur "Partager publiquement". Son client demande au serveur :
```
Alice ──▶ Signaling : { type: "create-public-room" }
Signaling ──▶ Alice : { type: "public-room-created", code: "x7k", url: "https://anydrop.arthurbarre.fr/x7k" }
```
L'UI affiche le QR code + l'URL + le code en gros.
### 2. Charlie ouvre l'URL
Charlie sur son téléphone scanne le QR → son navigateur ouvre `/x7k`. Son client se connecte au signaling avec l'intention de rejoindre la room `x7k` :
```
Charlie ──▶ Signaling : { type: "hello", joinCode: "x7k" }
Signaling ──▶ Charlie : { type: "welcome", peerId: "c3", ... }
Signaling ──▶ Alice : { type: "peer-joined", peerId: "c3", displayName: "Ours Vert" }
Signaling ──▶ Charlie : { type: "peer-joined", peerId: "a1", displayName: "Renard Bleu" }
```
### 3. Négociation WebRTC (comme au scénario 1)
Sauf que maintenant les candidates **host** (IPs locales) ne se joindront jamais (réseaux différents). Ce sont les candidates **srflx** (IP publique via STUN) qui vont s'apparier :
- NAT d'Alice fait un "hole punching" via STUN
- NAT de Charlie pareil
- Les paquets UDP se croisent et établissent le tunnel
Ça fonctionne dans **~80% des cas** avec juste STUN. Sinon → fallback TURN (relay).
### 4. Transfert
Même logique de chunks que scénario 1, mais débit limité par la bande passante internet (typiquement 10-100 Mo/s selon connexions).
---
## Pourquoi le P2P parfois échoue (→ TURN)
- NAT symétrique (certaines box 4G, firewalls d'entreprise) : le port change à chaque destination → STUN impuissant
- Firewall corporate bloquant UDP sortant
- Dans ces cas, TURN relaie le trafic via un serveur intermédiaire (coût bande passante réel)
On détecte l'échec de connectivité via `iceConnectionState === "failed"` et on peut afficher un message à l'utilisateur ou retenter avec TURN.
---
## Sécurité
- **DTLS obligatoire** : WebRTC refuse d'ouvrir un DataChannel non chiffré
- Les clés DTLS sont négociées dans le SDP, échangées via le signaling
- Un attaquant sur le signaling pourrait théoriquement faire du MITM en modifiant les empreintes DTLS dans le SDP → pour une V2, on peut ajouter une vérification out-of-band (ex : afficher une "safety number" à comparer)
- Pour la V1, on s'appuie sur HTTPS/WSS pour sécuriser le signaling

4
k8s/namespace.yml Normal file
View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: anydrop

68
k8s/server.yml Normal file
View File

@ -0,0 +1,68 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: anydrop-server-config
namespace: anydrop
data:
PORT: "3001"
BASE_URL: "https://anydrop.arthurbarre.fr"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: anydrop-server
namespace: anydrop
spec:
replicas: 1
selector:
matchLabels:
app: anydrop-server
template:
metadata:
labels:
app: anydrop-server
spec:
containers:
- name: anydrop-server
image: git.arthurbarre.fr/ordinarthur/anydrop-server:latest
ports:
- containerPort: 3001
envFrom:
- configMapRef:
name: anydrop-server-config
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"
imagePullSecrets:
- name: gitea-registry
---
apiVersion: v1
kind: Service
metadata:
name: anydrop-server
namespace: anydrop
spec:
type: ClusterIP
selector:
app: anydrop-server
ports:
- port: 3001
targetPort: 3001

56
k8s/web.yml Normal file
View File

@ -0,0 +1,56 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: anydrop-web
namespace: anydrop
spec:
replicas: 1
selector:
matchLabels:
app: anydrop-web
template:
metadata:
labels:
app: anydrop-web
spec:
containers:
- name: anydrop-web
image: git.arthurbarre.fr/ordinarthur/anydrop-web:latest
ports:
- containerPort: 80
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "64Mi"
cpu: "100m"
imagePullSecrets:
- name: gitea-registry
---
apiVersion: v1
kind: Service
metadata:
name: anydrop-web
namespace: anydrop
spec:
type: NodePort
selector:
app: anydrop-web
ports:
- port: 80
targetPort: 80
nodePort: 30097

9384
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "anydrop",
"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"
},
"devDependencies": {
"concurrently": "^9.1.2",
"typescript": "^5.7.3"
}
}

32
server/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json shared/
COPY server/package.json server/
COPY web/package.json web/
RUN npm ci
COPY tsconfig.base.json ./
COPY shared/ shared/
COPY server/ server/
RUN npm run build -w shared && npm run build -w server
# Runtime stage
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json shared/
COPY server/package.json server/
COPY web/package.json web/
RUN npm ci --omit=dev
COPY --from=build /app/shared/dist shared/dist
COPY --from=build /app/server/dist server/dist
ENV PORT=3001
EXPOSE 3001
CMD ["node", "server/dist/index.js"]

23
server/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "@anydrop/server",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anydrop/shared": "*",
"nanoid": "^5.1.5",
"ws": "^8.18.1"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/ws": "^8.18.1",
"tsx": "^4.19.4"
}
}

295
server/src/index.ts Normal file
View File

@ -0,0 +1,295 @@
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { networkInterfaces } from "node:os";
import { WebSocketServer, WebSocket } from "ws";
import { nanoid } from "nanoid";
import {
generateDisplayName,
type ClientMessage,
type ServerMessage,
type PeerInfo,
} from "@anydrop/shared";
import { RoomManager, type Client } from "./rooms.js";
const PORT = parseInt(process.env.PORT || "3001", 10);
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
const RATE_LIMIT_CONNECTIONS_PER_IP = 10;
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MESSAGES = 100;
// Track connection rate per IP
const connectionCounts = new Map<string, { count: number; windowStart: number }>();
function hashIP(ip: string): string {
return createHash("sha256").update(ip).digest("hex").slice(0, 16);
}
/** Strip ::ffff: prefix from IPv4-mapped IPv6 addresses */
function normalizeIP(ip: string): string {
return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
}
/** Check if an IPv4 address is private (RFC 1918) or loopback */
function isPrivateIP(ip: string): boolean {
return (
ip.startsWith("10.") ||
ip.startsWith("192.168.") ||
ip.startsWith("127.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip)
);
}
function isLoopback(ip: string): boolean {
return ip === "::1" || ip === "127.0.0.1" || ip.startsWith("127.");
}
/** Detect the server's own LAN subnet at startup (e.g. "192.168.1") */
function detectLanSubnet(): string | null {
const nets = networkInterfaces();
for (const ifaces of Object.values(nets)) {
if (!ifaces) continue;
for (const iface of ifaces) {
if (!iface.internal && iface.family === "IPv4" && isPrivateIP(iface.address)) {
const parts = iface.address.split(".");
return parts.slice(0, 3).join(".");
}
}
}
return null;
}
const serverLanSubnet = detectLanSubnet();
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
/**
* Extract the LAN grouping key from an IP.
* - Loopback (::1, 127.x): use the server's own LAN subnet so localhost
* clients end up in the same room as LAN clients.
* - Private IPs: use the /24 subnet (e.g. "192.168.1") so all devices
* on the same WiFi share one LAN room.
* - Public IPs: use the full IP (devices behind the same NAT share one).
*/
function getLanGroupKey(rawIP: string): string {
const ip = normalizeIP(rawIP);
if (isLoopback(ip) || ip === "unknown") {
return serverLanSubnet ?? "localhost";
}
if (isPrivateIP(ip)) {
const parts = ip.split(".");
return parts.slice(0, 3).join(".");
}
return ip;
}
function getClientIP(req: { headers: Record<string, string | string[] | undefined>; socket: { remoteAddress?: string } }): string {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0];
return first.trim();
}
return req.socket.remoteAddress || "unknown";
}
function send(ws: WebSocket, msg: ServerMessage): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function checkConnectionRate(ip: string): boolean {
const now = Date.now();
const entry = connectionCounts.get(ip);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
connectionCounts.set(ip, { count: 1, windowStart: now });
return true;
}
entry.count++;
return entry.count <= RATE_LIMIT_CONNECTIONS_PER_IP;
}
function checkMessageRate(client: Client): boolean {
const now = Date.now();
if (now - client.messageWindowStart > RATE_LIMIT_WINDOW_MS) {
client.messageCount = 1;
client.messageWindowStart = now;
return true;
}
client.messageCount++;
return client.messageCount <= RATE_LIMIT_MESSAGES;
}
// ── HTTP server (health check) ──
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();
});
// ── WebSocket server ──
const wss = new WebSocketServer({ server: httpServer });
const roomManager = new RoomManager();
const clients = new Map<WebSocket, Client>();
wss.on("connection", (ws, req) => {
const ip = getClientIP(req as any);
const groupKey = getLanGroupKey(ip);
console.log(`[connect] raw IP: ${ip} → groupKey: ${groupKey} → room: lan:${hashIP(groupKey)}`);
if (!checkConnectionRate(ip)) {
send(ws, { type: "error", code: "rate-limit", message: "Too many connections" });
ws.close();
return;
}
const peerId = nanoid(8);
const displayName = generateDisplayName();
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
const client: Client = {
ws,
peerId,
displayName,
ip,
lanRoomId,
publicRoomId: null,
messageCount: 0,
messageWindowStart: Date.now(),
};
clients.set(ws, client);
ws.on("message", (raw) => {
if (!checkMessageRate(client)) {
send(ws, { type: "error", code: "rate-limit", message: "Too many messages" });
return;
}
let msg: ClientMessage;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
switch (msg.type) {
case "hello":
handleHello(client, msg.joinCode);
break;
case "create-public-room":
handleCreatePublicRoom(client);
break;
case "signal":
handleSignal(client, msg.to, msg.data);
break;
case "leave":
handleLeave(client);
break;
}
});
ws.on("close", () => {
handleLeave(client);
clients.delete(ws);
});
});
function handleHello(client: Client, joinCode?: string): void {
// Join LAN room
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
roomManager.addClientToRoom(lanRoom, client);
const peers: PeerInfo[] = [];
for (const peer of lanRoom.clients.values()) {
if (peer.peerId !== client.peerId) {
peers.push({ peerId: peer.peerId, displayName: peer.displayName });
// Notify existing peers
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName });
}
}
console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`);
// Join public room if code provided
if (joinCode) {
const pubRoom = roomManager.getPublicRoomByCode(joinCode);
if (!pubRoom) {
send(client.ws, { type: "error", code: "room-not-found", message: `Room "${joinCode}" not found or expired` });
} else {
client.publicRoomId = pubRoom.id;
roomManager.addClientToRoom(pubRoom, client);
for (const peer of pubRoom.clients.values()) {
if (peer.peerId !== client.peerId) {
// Avoid duplicate if peer is already in LAN peers
if (!peers.find((p) => p.peerId === peer.peerId)) {
peers.push({ peerId: peer.peerId, displayName: peer.displayName });
}
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName });
}
}
}
}
send(client.ws, {
type: "welcome",
peerId: client.peerId,
displayName: client.displayName,
roomId: client.lanRoomId,
peers,
});
}
function handleCreatePublicRoom(client: Client): void {
try {
const { code, roomId, expiresAt } = roomManager.createPublicRoom();
client.publicRoomId = roomId;
const room = roomManager.getPublicRoomByCode(code)!;
roomManager.addClientToRoom(room, client);
send(client.ws, {
type: "public-room-created",
code,
url: `${BASE_URL}/${code}`,
expiresAt: new Date(expiresAt).toISOString(),
});
} catch {
send(client.ws, { type: "error", code: "rate-limit", message: "Failed to create room" });
}
}
function handleSignal(client: Client, to: string, data: unknown): void {
const target = roomManager.findPeerInClientRooms(client, to);
if (target) {
send(target.ws, { type: "signal", from: client.peerId, data });
}
}
function handleLeave(client: Client): void {
// Remove from LAN room
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
roomManager.removeClientFromRoom(lanRoom, client.peerId);
for (const peer of lanRoom.clients.values()) {
send(peer.ws, { type: "peer-left", peerId: client.peerId });
}
// Remove from public room
if (client.publicRoomId) {
const pubRoom = roomManager.getRoomById(client.publicRoomId);
if (pubRoom) {
roomManager.removeClientFromRoom(pubRoom, client.peerId);
for (const peer of pubRoom.clients.values()) {
send(peer.ws, { type: "peer-left", peerId: client.peerId });
}
}
client.publicRoomId = null;
}
}
httpServer.listen(PORT, () => {
console.log(`AnyDrop signaling server running on port ${PORT}`);
});

171
server/src/rooms.ts Normal file
View File

@ -0,0 +1,171 @@
import { WebSocket } from "ws";
import { customAlphabet } from "nanoid";
import {
SHORT_CODE_ALPHABET,
SHORT_CODE_LENGTH,
SHORT_CODE_MAX_LENGTH,
SHORT_CODE_MAX_RETRIES,
PUBLIC_ROOM_TTL_MS,
} from "@anydrop/shared";
const generateCode = customAlphabet(SHORT_CODE_ALPHABET, SHORT_CODE_LENGTH);
const generateLongCode = customAlphabet(SHORT_CODE_ALPHABET, SHORT_CODE_MAX_LENGTH);
export interface Client {
ws: WebSocket;
peerId: string;
displayName: string;
ip: string;
lanRoomId: string;
publicRoomId: string | null;
messageCount: number;
messageWindowStart: number;
}
export interface Room {
id: string;
type: "lan" | "public";
clients: Map<string, Client>;
code?: string;
expiresAt?: number;
lastActivity: number;
}
export class RoomManager {
private rooms = new Map<string, Room>();
private publicCodeIndex = new Map<string, string>(); // code → roomId
private expirationTimer: ReturnType<typeof setInterval>;
constructor() {
this.expirationTimer = setInterval(() => this.cleanExpiredRooms(), 60_000);
}
getLanRoomId(ip: string): string {
return `lan:${ip}`;
}
getOrCreateLanRoom(ip: string): Room {
const id = this.getLanRoomId(ip);
let room = this.rooms.get(id);
if (!room) {
room = {
id,
type: "lan",
clients: new Map(),
lastActivity: Date.now(),
};
this.rooms.set(id, room);
}
return room;
}
createPublicRoom(): { code: string; roomId: string; expiresAt: number } {
let code: string | null = null;
for (let i = 0; i < SHORT_CODE_MAX_RETRIES; i++) {
const candidate = generateCode();
if (!this.publicCodeIndex.has(candidate)) {
code = candidate;
break;
}
}
if (!code) {
for (let i = 0; i < SHORT_CODE_MAX_RETRIES; i++) {
const candidate = generateLongCode();
if (!this.publicCodeIndex.has(candidate)) {
code = candidate;
break;
}
}
}
if (!code) {
throw new Error("Failed to generate unique room code");
}
const roomId = `pub:${code}`;
const expiresAt = Date.now() + PUBLIC_ROOM_TTL_MS;
const room: Room = {
id: roomId,
type: "public",
clients: new Map(),
code,
expiresAt,
lastActivity: Date.now(),
};
this.rooms.set(roomId, room);
this.publicCodeIndex.set(code, roomId);
return { code, roomId, expiresAt };
}
getPublicRoomByCode(code: string): Room | null {
const roomId = this.publicCodeIndex.get(code);
if (!roomId) return null;
const room = this.rooms.get(roomId);
if (!room) return null;
if (room.expiresAt && Date.now() > room.expiresAt) {
this.deleteRoom(roomId);
return null;
}
return room;
}
addClientToRoom(room: Room, client: Client): void {
room.clients.set(client.peerId, client);
room.lastActivity = Date.now();
}
removeClientFromRoom(room: Room, peerId: string): void {
room.clients.delete(peerId);
if (room.clients.size === 0 && room.type === "lan") {
this.rooms.delete(room.id);
}
}
touchRoom(room: Room): void {
room.lastActivity = Date.now();
if (room.expiresAt) {
room.expiresAt = Date.now() + PUBLIC_ROOM_TTL_MS;
}
}
getRoomById(roomId: string): Room | null {
return this.rooms.get(roomId) || null;
}
findPeerInClientRooms(client: Client, targetPeerId: string): Client | null {
const lanRoom = this.rooms.get(client.lanRoomId);
if (lanRoom?.clients.has(targetPeerId)) {
return lanRoom.clients.get(targetPeerId)!;
}
if (client.publicRoomId) {
const pubRoom = this.rooms.get(client.publicRoomId);
if (pubRoom?.clients.has(targetPeerId)) {
return pubRoom.clients.get(targetPeerId)!;
}
}
return null;
}
private deleteRoom(roomId: string): void {
const room = this.rooms.get(roomId);
if (room?.code) {
this.publicCodeIndex.delete(room.code);
}
this.rooms.delete(roomId);
}
private cleanExpiredRooms(): void {
const now = Date.now();
for (const [id, room] of this.rooms) {
if (room.expiresAt && now > room.expiresAt) {
for (const client of room.clients.values()) {
client.publicRoomId = null;
}
this.deleteRoom(id);
}
}
}
destroy(): void {
clearInterval(this.expirationTimer);
}
}

13
server/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"types": ["node"]
},
"include": ["src"],
"references": [
{ "path": "../shared" }
]
}

View File

@ -0,0 +1 @@
{"root":["./src/index.ts","./src/rooms.ts"],"version":"5.8.3"}

19
shared/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "@anydrop/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc --watch"
}
}

2
shared/src/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./protocol.js";
export * from "./names.js";

21
shared/src/names.ts Normal file
View File

@ -0,0 +1,21 @@
const ADJECTIVES = [
"Rouge", "Bleu", "Vert", "Doré", "Violet",
"Blanc", "Noir", "Rose", "Gris", "Orange",
"Rapide", "Calme", "Brave", "Agile", "Sage",
"Vif", "Fier", "Noble", "Grand", "Petit",
];
const ANIMALS = [
"Renard", "Tigre", "Ours", "Loup", "Aigle",
"Dauphin", "Faucon", "Lynx", "Panda", "Lion",
"Chat", "Hibou", "Cerf", "Koala", "Phoque",
"Colibri", "Loutre", "Requin", "Corbeau", "Furet",
];
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function generateDisplayName(): string {
return `${pick(ANIMALS)} ${pick(ADJECTIVES)}`;
}

137
shared/src/protocol.ts Normal file
View File

@ -0,0 +1,137 @@
// ── Client → Server messages ──
export interface HelloMessage {
type: "hello";
joinCode?: string;
}
export interface CreatePublicRoomMessage {
type: "create-public-room";
}
export interface SignalMessage {
type: "signal";
to: string;
data: unknown;
}
export interface LeaveMessage {
type: "leave";
}
export type ClientMessage =
| HelloMessage
| CreatePublicRoomMessage
| SignalMessage
| LeaveMessage;
// ── Server → Client messages ──
export interface PeerInfo {
peerId: string;
displayName: string;
}
export interface WelcomeMessage {
type: "welcome";
peerId: string;
displayName: string;
roomId: string;
peers: PeerInfo[];
}
export interface PublicRoomCreatedMessage {
type: "public-room-created";
code: string;
url: string;
expiresAt: string;
}
export interface PeerJoinedMessage {
type: "peer-joined";
peerId: string;
displayName: string;
}
export interface PeerLeftMessage {
type: "peer-left";
peerId: string;
}
export interface SignalRelayMessage {
type: "signal";
from: string;
data: unknown;
}
export type ErrorCode = "room-not-found" | "room-expired" | "rate-limit";
export interface ErrorMessage {
type: "error";
code: ErrorCode;
message: string;
}
export type ServerMessage =
| WelcomeMessage
| PublicRoomCreatedMessage
| PeerJoinedMessage
| PeerLeftMessage
| SignalRelayMessage
| ErrorMessage;
// ── Data channel messages (peer-to-peer) ──
export interface FileMetaMessage {
type: "file-meta";
id: string;
name: string;
size: number;
mime: string;
}
export interface FileEndMessage {
type: "file-end";
id: string;
}
export interface TextMessage {
type: "text";
id: string;
content: string;
}
export interface TransferRequestMessage {
type: "transfer-request";
files: { id: string; name: string; size: number; mime: string }[];
text?: string;
}
export interface TransferResponseMessage {
type: "transfer-response";
accepted: boolean;
}
export type DataChannelMessage =
| FileMetaMessage
| FileEndMessage
| TextMessage
| TransferRequestMessage
| TransferResponseMessage;
// ── Constants ──
export const CHUNK_SIZE = 16 * 1024; // 16 KB
export const MAX_BUFFER = 1024 * 1024; // 1 MB backpressure threshold
export const SHORT_CODE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
export const SHORT_CODE_LENGTH = 3;
export const SHORT_CODE_MAX_LENGTH = 4;
export const SHORT_CODE_MAX_RETRIES = 5;
export const PUBLIC_ROOM_TTL_MS = 10 * 60 * 1000; // 10 minutes
export const ICE_SERVERS: RTCIceServer[] = [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
];

9
shared/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src"]
}

File diff suppressed because one or more lines are too long

16
tsconfig.base.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}

25
web/Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
ARG VITE_WS_URL
COPY package.json package-lock.json ./
COPY shared/package.json shared/
COPY server/package.json server/
COPY web/package.json web/
RUN npm ci
COPY tsconfig.base.json ./
COPY shared/ shared/
COPY web/ web/
RUN npm run build -w shared && cd web && npx vite build
# Runtime stage
FROM nginx:alpine
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/web/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
web/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="AnyDrop — Partage de fichiers instantané, peer-to-peer, sans compte" />
<meta name="theme-color" content="#6366f1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>AnyDrop</title>
</head>
<body class="bg-slate-950 text-white antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24
web/nginx.conf Normal file
View File

@ -0,0 +1,24 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA routing fallback to index.html
location / {
try_files $uri $uri/ /index.html;
}
# WebSocket proxy to signaling server
location /ws {
proxy_pass http://anydrop-server:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}

35
web/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "@anydrop/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anydrop/shared": "*",
"events": "^3.3.0",
"process": "^0.11.10",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.5.1",
"simple-peer": "^9.11.1",
"zustand": "^5.0.5"
},
"devDependencies": {
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@types/simple-peer": "^9.11.8",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"vite": "^6.3.3",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-pwa": "^1.0.0"
}
}

6
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

5
web/public/favicon.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#6366f1"/>
<path d="M50 20 L50 60 M35 45 L50 60 L65 45" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="25" y1="75" x2="75" y2="75" stroke="white" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

12
web/src/App.tsx Normal file
View File

@ -0,0 +1,12 @@
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import JoinRoom from "./pages/JoinRoom";
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/:code" element={<JoinRoom />} />
</Routes>
);
}

View File

@ -0,0 +1,101 @@
import { useState, useRef, useCallback } from "react";
interface DropZoneProps {
onFilesSelected: (files: File[]) => void;
disabled?: boolean;
}
export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounter.current++;
if (e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
dragCounter.current = 0;
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onFilesSelected(files);
}
},
[onFilesSelected, disabled],
);
const handleClick = () => {
if (!disabled) {
inputRef.current?.click();
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
onFilesSelected(files);
}
// Reset to allow re-selecting the same file
e.target.value = "";
};
return (
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleClick}
className={`
border-2 border-dashed rounded-2xl p-8
flex flex-col items-center justify-center gap-3
transition-all duration-200 cursor-pointer
min-h-[160px]
${isDragging
? "border-brand-400 bg-brand-500/10 scale-[1.02]"
: disabled
? "border-slate-700 bg-slate-900/50 cursor-not-allowed opacity-50"
: "border-slate-700 hover:border-slate-500 bg-slate-900/30 hover:bg-slate-900/50"
}
`}
>
<div className="text-4xl">{isDragging ? "📥" : "📁"}</div>
<p className="text-slate-400 text-sm text-center">
{disabled
? "Sélectionnez d'abord un appareil"
: isDragging
? "Déposez vos fichiers ici"
: "Glissez des fichiers ici ou cliquez pour sélectionner"
}
</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}

View File

@ -0,0 +1,53 @@
interface PeerAvatarProps {
displayName: string;
onClick?: () => void;
isSelected?: boolean;
size?: "sm" | "md" | "lg";
}
const ANIMAL_EMOJIS: Record<string, string> = {
Renard: "🦊", Tigre: "🐯", Ours: "🐻", Loup: "🐺", Aigle: "🦅",
Dauphin: "🐬", Faucon: "🦅", Lynx: "🐱", Panda: "🐼", Lion: "🦁",
Chat: "🐱", Hibou: "🦉", Cerf: "🦌", Koala: "🐨", Phoque: "🦭",
Colibri: "🐦", Loutre: "🦦", Requin: "🦈", Corbeau: "🐦‍⬛", Furet: "🐾",
};
function getEmoji(displayName: string): string {
const animal = displayName.split(" ")[0];
return ANIMAL_EMOJIS[animal] || "📱";
}
const sizeClasses = {
sm: "w-12 h-12 text-xl",
md: "w-16 h-16 text-2xl",
lg: "w-20 h-20 text-3xl",
};
export default function PeerAvatar({ displayName, onClick, isSelected, size = "md" }: PeerAvatarProps) {
return (
<button
onClick={onClick}
className={`
flex flex-col items-center gap-2 group cursor-pointer
transition-transform duration-200 hover:scale-105
`}
>
<div
className={`
${sizeClasses[size]}
rounded-full flex items-center justify-center
transition-all duration-200
${isSelected
? "bg-brand-500 ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
: "bg-slate-800 hover:bg-slate-700"
}
`}
>
{getEmoji(displayName)}
</div>
<span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate">
{displayName}
</span>
</button>
);
}

View File

@ -0,0 +1,37 @@
import { useStore } from "../stores/useStore";
import PeerAvatar from "./PeerAvatar";
interface PeerListProps {
onPeerSelect: (peerId: string) => void;
}
export default function PeerList({ onPeerSelect }: PeerListProps) {
const peers = useStore((s) => s.peers);
const selectedPeerId = useStore((s) => s.selectedPeerId);
if (peers.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<div className="text-5xl mb-4">📡</div>
<p className="text-lg font-medium">En attente d'appareils...</p>
<p className="text-sm mt-2 text-center max-w-xs">
Ouvrez AnyDrop sur un autre appareil connecté au même Wi-Fi,
ou partagez un lien public.
</p>
</div>
);
}
return (
<div className="flex flex-wrap justify-center gap-6 py-8">
{peers.map((peer) => (
<PeerAvatar
key={peer.peerId}
displayName={peer.displayName}
isSelected={selectedPeerId === peer.peerId}
onClick={() => onPeerSelect(peer.peerId)}
/>
))}
</div>
);
}

View File

@ -0,0 +1,61 @@
import { QRCodeSVG } from "qrcode.react";
import { useStore } from "../stores/useStore";
interface PublicRoomPanelProps {
onCreateRoom: () => void;
}
export default function PublicRoomPanel({ onCreateRoom }: PublicRoomPanelProps) {
const publicRoomCode = useStore((s) => s.publicRoomCode);
const publicRoomUrl = useStore((s) => s.publicRoomUrl);
if (!publicRoomCode || !publicRoomUrl) {
return (
<button
onClick={onCreateRoom}
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="text-lg">🔗</span>
<span className="text-sm font-medium">Partager via lien public</span>
</button>
);
}
const copyToClipboard = () => {
navigator.clipboard.writeText(publicRoomUrl);
};
return (
<div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6 text-center space-y-4">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Lien public
</h3>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-xl">
<QRCodeSVG value={publicRoomUrl} size={160} />
</div>
</div>
<div className="space-y-2">
<p className="text-3xl font-mono font-bold text-brand-400 tracking-widest">
{publicRoomCode.toUpperCase()}
</p>
<button
onClick={copyToClipboard}
className="text-sm text-slate-400 hover:text-white transition-colors
underline underline-offset-4 decoration-slate-600"
>
{publicRoomUrl}
</button>
</div>
<p className="text-xs text-slate-500">
Partagez ce QR code ou ce lien pour recevoir des fichiers de n'importe qui.
</p>
</div>
);
}

View File

@ -0,0 +1,74 @@
import type { IncomingRequest } from "../stores/useStore";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
}
interface ReceiveDialogProps {
request: IncomingRequest;
onAccept: () => void;
onReject: () => void;
}
export default function ReceiveDialog({ request, onAccept, onReject }: ReceiveDialogProps) {
const totalSize = request.files.reduce((sum, f) => sum + f.size, 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-2">
Transfert entrant
</h2>
<p className="text-sm text-slate-400 mb-4">
<span className="text-brand-400 font-medium">{request.displayName}</span> veut vous envoyer :
</p>
{request.files.length > 0 && (
<div className="bg-slate-800/50 rounded-xl p-3 mb-4 space-y-2 max-h-40 overflow-y-auto">
{request.files.map((file) => (
<div key={file.id} className="flex items-center justify-between text-sm">
<span className="text-white truncate mr-2">{file.name}</span>
<span className="text-slate-500 text-xs whitespace-nowrap">{formatSize(file.size)}</span>
</div>
))}
{request.files.length > 1 && (
<div className="border-t border-slate-700 pt-2 flex justify-between text-xs text-slate-400">
<span>{request.files.length} fichiers</span>
<span>{formatSize(totalSize)}</span>
</div>
)}
</div>
)}
{request.text && (
<div className="bg-slate-800/50 rounded-xl p-3 mb-4">
<p className="text-xs text-slate-500 mb-1">Texte :</p>
<p className="text-sm text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{request.text}
</p>
</div>
)}
<div className="flex gap-3">
<button
onClick={onReject}
className="flex-1 px-4 py-2.5 border border-slate-600 text-slate-300
hover:bg-slate-800 rounded-xl text-sm font-medium transition-colors"
>
Refuser
</button>
<button
onClick={onAccept}
className="flex-1 px-4 py-2.5 bg-brand-600 hover:bg-brand-500
text-white rounded-xl text-sm font-medium transition-colors"
>
{request.text && request.files.length === 0 ? "Copier" : "Accepter"}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import { useState } from "react";
interface TextShareModalProps {
onSend: (text: string) => void;
onClose: () => void;
}
export default function TextShareModal({ onSend, onClose }: TextShareModalProps) {
const [text, setText] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (text.trim()) {
onSend(text.trim());
onClose();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4">Envoyer du texte</h2>
<form onSubmit={handleSubmit}>
<textarea
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Tapez votre message, lien, ou texte..."
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-xl p-3 text-white
placeholder-slate-500 resize-none focus:outline-none focus:ring-2
focus:ring-brand-500 focus:border-transparent"
/>
<div className="flex justify-end gap-3 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={!text.trim()}
className="px-6 py-2 bg-brand-600 hover:bg-brand-500 disabled:opacity-50
disabled:cursor-not-allowed text-white text-sm font-medium
rounded-xl transition-colors"
>
Envoyer
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
import { useStore } from "../stores/useStore";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
}
export default function TransferProgress() {
const transfers = useStore((s) => s.transfers);
const removeTransfer = useStore((s) => s.removeTransfer);
const activeTransfers = transfers.filter((t) => t.status !== "done" || Date.now() < Date.now()); // Show all for now
if (activeTransfers.length === 0) return null;
return (
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Transferts
</h3>
{activeTransfers.map((transfer) => (
<div
key={transfer.id}
className="bg-slate-800/50 rounded-xl p-4 flex items-center gap-4"
>
<div className="text-2xl">
{transfer.direction === "send" ? "📤" : "📥"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{transfer.fileName}</p>
<p className="text-xs text-slate-500">{formatSize(transfer.fileSize)}</p>
{transfer.status === "transferring" && (
<div className="mt-2 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-brand-500 rounded-full transition-all duration-300"
style={{ width: `${Math.round(transfer.progress * 100)}%` }}
/>
</div>
)}
</div>
<div className="text-right">
{transfer.status === "pending" && (
<span className="text-xs text-yellow-400">En attente</span>
)}
{transfer.status === "transferring" && (
<span className="text-xs text-brand-400">
{Math.round(transfer.progress * 100)}%
</span>
)}
{transfer.status === "done" && (
<button
onClick={() => removeTransfer(transfer.id)}
className="text-xs text-green-400 hover:text-green-300"
>
Terminé
</button>
)}
{transfer.status === "error" && (
<span className="text-xs text-red-400">Erreur</span>
)}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,330 @@
import { useEffect, useRef, useCallback } from "react";
import type { ServerMessage, TransferRequestMessage, TransferResponseMessage } from "@anydrop/shared";
import { SignalingClient } from "../lib/signaling";
import { PeerManager } from "../lib/peerManager";
import {
sendFile,
createTransferRequest,
createTransferResponse,
createFileReceiver,
} from "../lib/fileTransfer";
import { useStore } from "../stores/useStore";
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function useSignaling(joinCode?: string) {
const signalingRef = useRef<SignalingClient | null>(null);
const peerManagerRef = useRef<PeerManager | null>(null);
const pendingFilesRef = useRef<Map<string, { files: File[]; text?: string }>>(new Map());
const fileReceiverRef = useRef<ReturnType<typeof createFileReceiver> | null>(null);
const {
setConnection,
setConnected,
addPeer,
removePeer,
setPeers,
setPublicRoom,
addTransfer,
updateTransfer,
setIncomingRequest,
setError,
} = useStore();
// Initialize file receiver
useEffect(() => {
fileReceiverRef.current = createFileReceiver({
onData: () => {},
onComplete: (fileId, blob, fileName) => {
updateTransfer(fileId, { progress: 1, status: "done" });
downloadBlob(blob, fileName);
},
onProgress: (fileId, progress) => {
updateTransfer(fileId, { progress, status: "transferring" });
},
onTransferRequest: (msg: TransferRequestMessage) => {
// Find the peer who sent this
const store = useStore.getState();
const fromPeer = store.peers.find(() => true); // We'll set this from data handler
setIncomingRequest({
peerId: "", // Will be set from the data handler
displayName: "",
files: msg.files,
text: msg.text,
});
},
onTransferResponse: (msg: TransferResponseMessage) => {
if (msg.accepted) {
// Start sending files
startSending();
} else {
// Clean up pending transfers
const store = useStore.getState();
for (const transfer of store.transfers) {
if (transfer.direction === "send" && transfer.status === "pending") {
updateTransfer(transfer.id, { status: "error" });
}
}
}
},
onText: (text) => {
// Show received text to user
const store = useStore.getState();
setIncomingRequest({
peerId: "",
displayName: "",
files: [],
text,
});
},
});
}, []);
const startSending = useCallback(() => {
const store = useStore.getState();
for (const [peerId, { files }] of pendingFilesRef.current) {
const pm = peerManagerRef.current;
if (!pm) continue;
const peer = pm.getPeer(peerId);
if (!peer) continue;
for (const file of files) {
const transfer = store.transfers.find(
(t) => t.direction === "send" && t.fileName === file.name && t.status === "pending",
);
if (transfer) {
sendFile(peer, file, transfer.id, (progress) => {
updateTransfer(transfer.id, {
progress,
status: progress >= 1 ? "done" : "transferring",
});
});
}
}
}
pendingFilesRef.current.clear();
}, [updateTransfer]);
useEffect(() => {
const handleMessage = (msg: ServerMessage) => {
switch (msg.type) {
case "welcome":
setConnection(msg.peerId, msg.displayName, msg.roomId);
setPeers(msg.peers);
break;
case "peer-joined":
addPeer({ peerId: msg.peerId, displayName: msg.displayName });
break;
case "peer-left":
removePeer(msg.peerId);
peerManagerRef.current?.closePeer(msg.peerId);
break;
case "signal":
peerManagerRef.current?.handleSignal(msg.from, msg.data);
break;
case "public-room-created":
setPublicRoom(msg.code, msg.url, msg.expiresAt);
break;
case "error":
setError(msg.message);
break;
}
};
const signaling = new SignalingClient(handleMessage, joinCode);
signalingRef.current = signaling;
const peerManager = new PeerManager(signaling, {
onConnect: (peerId) => {
console.log(`P2P connected with ${peerId}`);
},
onClose: (peerId) => {
console.log(`P2P disconnected from ${peerId}`);
},
onData: (peerId, data) => {
// Update incoming request with correct peerId
const store = useStore.getState();
const peerInfo = store.peers.find((p) => p.peerId === peerId);
if (typeof data === "string") {
try {
const msg = JSON.parse(data);
if (msg.type === "transfer-request") {
setIncomingRequest({
peerId,
displayName: peerInfo?.displayName || peerId,
files: msg.files,
text: msg.text,
});
return;
}
if (msg.type === "transfer-response") {
if (msg.accepted) {
startSending();
}
return;
}
if (msg.type === "text") {
setIncomingRequest({
peerId,
displayName: peerInfo?.displayName || peerId,
files: [],
text: msg.content,
});
return;
}
} catch {
// Not JSON
}
}
fileReceiverRef.current?.handleData(data);
},
onError: (peerId, err) => {
console.error(`P2P error with ${peerId}:`, err);
},
});
peerManagerRef.current = peerManager;
signaling.connect();
setConnected(true);
return () => {
signaling.disconnect();
peerManager.closeAll();
setConnected(false);
};
}, [joinCode]);
const sendFiles = useCallback((peerId: string, files: File[]) => {
const pm = peerManagerRef.current;
const signaling = signalingRef.current;
if (!pm || !signaling) return;
// Ensure P2P connection exists
let peer = pm.getPeer(peerId);
if (!peer) {
peer = pm.createPeer(peerId, true);
}
const request = createTransferRequest(files);
// Add transfers to store
for (const fileMeta of request.files) {
const file = files.find((f) => f.name === fileMeta.name)!;
addTransfer({
id: fileMeta.id,
peerId,
fileName: fileMeta.name,
fileSize: fileMeta.size,
mime: fileMeta.mime,
direction: "send",
progress: 0,
status: "pending",
});
}
// Store files for when accepted
pendingFilesRef.current.set(peerId, { files });
// Wait for connection then send request
const sendRequest = () => {
const p = pm.getPeer(peerId);
if (p && (p as any)._channel?.readyState === "open") {
p.send(JSON.stringify(request));
} else {
// Peer is connected but channel might not be ready
setTimeout(sendRequest, 100);
}
};
if (peer && (peer as any).connected) {
sendRequest();
} else {
peer.on("connect", sendRequest);
}
}, [addTransfer]);
const sendText = useCallback((peerId: string, text: string) => {
const pm = peerManagerRef.current;
if (!pm) return;
let peer = pm.getPeer(peerId);
if (!peer) {
peer = pm.createPeer(peerId, true);
}
const sendMsg = () => {
const p = pm.getPeer(peerId);
if (p) {
p.send(JSON.stringify({ type: "text", id: `t-${Date.now()}`, content: text }));
}
};
if (peer && (peer as any).connected) {
sendMsg();
} else {
peer.on("connect", sendMsg);
}
}, []);
const acceptTransfer = useCallback((peerId: string) => {
const pm = peerManagerRef.current;
if (!pm) return;
const peer = pm.getPeer(peerId);
if (!peer) return;
// Add receive transfers
const store = useStore.getState();
const request = store.incomingRequest;
if (request) {
for (const file of request.files) {
addTransfer({
id: file.id,
peerId,
fileName: file.name,
fileSize: file.size,
mime: file.mime,
direction: "receive",
progress: 0,
status: "pending",
});
}
}
peer.send(JSON.stringify(createTransferResponse(true)));
setIncomingRequest(null);
}, [addTransfer, setIncomingRequest]);
const rejectTransfer = useCallback((peerId: string) => {
const pm = peerManagerRef.current;
if (!pm) return;
const peer = pm.getPeer(peerId);
if (peer) {
peer.send(JSON.stringify(createTransferResponse(false)));
}
setIncomingRequest(null);
}, [setIncomingRequest]);
const createPublicRoom = useCallback(() => {
signalingRef.current?.send({ type: "create-public-room" });
}, []);
return {
sendFiles,
sendText,
acceptTransfer,
rejectTransfer,
createPublicRoom,
};
}

9
web/src/index.css Normal file
View File

@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply min-h-screen;
}
}

173
web/src/lib/fileTransfer.ts Normal file
View File

@ -0,0 +1,173 @@
import type SimplePeer from "simple-peer";
import {
CHUNK_SIZE,
MAX_BUFFER,
type FileMetaMessage,
type FileEndMessage,
type TransferRequestMessage,
type TransferResponseMessage,
type DataChannelMessage,
} from "@anydrop/shared";
export function createTransferRequest(
files: File[],
text?: string,
): TransferRequestMessage {
return {
type: "transfer-request",
files: files.map((f, i) => ({
id: `f${i}-${Date.now()}`,
name: f.name,
size: f.size,
mime: f.type || "application/octet-stream",
})),
text,
};
}
export function createTransferResponse(accepted: boolean): TransferResponseMessage {
return { type: "transfer-response", accepted };
}
export async function sendFile(
peer: SimplePeer.Instance,
file: File,
fileId: string,
onProgress: (progress: number) => void,
): Promise<void> {
// Send file metadata
const meta: FileMetaMessage = {
type: "file-meta",
id: fileId,
name: file.name,
size: file.size,
mime: file.type || "application/octet-stream",
};
peer.send(JSON.stringify(meta));
// Stream file in chunks
let offset = 0;
const reader = file.stream().getReader();
let buffer = new Uint8Array(0);
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + value.length);
newBuffer.set(buffer);
newBuffer.set(value, buffer.length);
buffer = newBuffer;
// Send complete chunks from buffer
while (buffer.length >= CHUNK_SIZE) {
const chunk = buffer.slice(0, CHUNK_SIZE);
buffer = buffer.slice(CHUNK_SIZE);
// Backpressure: wait if buffer is full
await waitForDrain(peer);
peer.send(chunk);
offset += chunk.length;
onProgress(offset / file.size);
}
}
// Send remaining bytes
if (buffer.length > 0) {
await waitForDrain(peer);
peer.send(buffer);
offset += buffer.length;
onProgress(offset / file.size);
}
// Send end-of-file marker
const eof: FileEndMessage = { type: "file-end", id: fileId };
peer.send(JSON.stringify(eof));
onProgress(1);
}
function waitForDrain(peer: SimplePeer.Instance): Promise<void> {
return new Promise((resolve) => {
const check = () => {
const channel = (peer as any)._channel as RTCDataChannel | undefined;
if (!channel || channel.bufferedAmount < MAX_BUFFER) {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
});
}
export interface FileReceiver {
onData: (data: Uint8Array | string) => void;
onComplete: (fileId: string, blob: Blob, fileName: string) => void;
onProgress: (fileId: string, progress: number) => void;
onTransferRequest: (msg: TransferRequestMessage) => void;
onTransferResponse: (msg: TransferResponseMessage) => void;
onText: (text: string) => void;
}
export function createFileReceiver(callbacks: FileReceiver) {
const receiving = new Map<
string,
{ name: string; size: number; mime: string; chunks: Uint8Array[]; received: number }
>();
return {
handleData(data: Uint8Array | string) {
// Try to parse as JSON control message
if (typeof data === "string") {
try {
const msg: DataChannelMessage = JSON.parse(data);
switch (msg.type) {
case "file-meta":
receiving.set(msg.id, {
name: msg.name,
size: msg.size,
mime: msg.mime,
chunks: [],
received: 0,
});
break;
case "file-end": {
const file = receiving.get(msg.id);
if (file) {
const blob = new Blob(file.chunks, { type: file.mime });
callbacks.onComplete(msg.id, blob, file.name);
receiving.delete(msg.id);
}
break;
}
case "text":
callbacks.onText(msg.content);
break;
case "transfer-request":
callbacks.onTransferRequest(msg);
break;
case "transfer-response":
callbacks.onTransferResponse(msg);
break;
}
} catch {
// Not JSON, ignore
}
return;
}
// Binary data = file chunk
// Find which file we're receiving (first active one)
for (const [id, file] of receiving) {
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
file.chunks.push(chunk);
file.received += chunk.length;
callbacks.onProgress(id, file.received / file.size);
break;
}
},
};
}

View File

@ -0,0 +1,95 @@
import SimplePeer from "simple-peer";
import { ICE_SERVERS } from "@anydrop/shared";
import type { SignalingClient } from "./signaling";
export interface PeerConnectionCallbacks {
onConnect: (peerId: string) => void;
onClose: (peerId: string) => void;
onData: (peerId: string, data: Uint8Array | string) => void;
onError: (peerId: string, err: Error) => void;
}
export class PeerManager {
private peers = new Map<string, SimplePeer.Instance>();
private signaling: SignalingClient;
private callbacks: PeerConnectionCallbacks;
constructor(signaling: SignalingClient, callbacks: PeerConnectionCallbacks) {
this.signaling = signaling;
this.callbacks = callbacks;
}
createPeer(peerId: string, initiator: boolean): SimplePeer.Instance {
// Close existing connection if any
this.closePeer(peerId);
const peer = new SimplePeer({
initiator,
trickle: true,
config: { iceServers: ICE_SERVERS },
});
peer.on("signal", (data) => {
this.signaling.send({ type: "signal", to: peerId, data });
});
peer.on("connect", () => {
this.callbacks.onConnect(peerId);
});
peer.on("data", (data: Uint8Array) => {
// Try to decode as string first (for JSON messages)
try {
const text = new TextDecoder().decode(data);
// Check if it looks like JSON
if (text.startsWith("{")) {
this.callbacks.onData(peerId, text);
return;
}
} catch {
// Not text, treat as binary
}
this.callbacks.onData(peerId, data);
});
peer.on("close", () => {
this.peers.delete(peerId);
this.callbacks.onClose(peerId);
});
peer.on("error", (err) => {
this.callbacks.onError(peerId, err);
});
this.peers.set(peerId, peer);
return peer;
}
handleSignal(fromPeerId: string, data: unknown): void {
let peer = this.peers.get(fromPeerId);
if (!peer) {
// Create a non-initiator peer for incoming connections
peer = this.createPeer(fromPeerId, false);
}
peer.signal(data as SimplePeer.SignalData);
}
getPeer(peerId: string): SimplePeer.Instance | undefined {
return this.peers.get(peerId);
}
closePeer(peerId: string): void {
const peer = this.peers.get(peerId);
if (peer) {
peer.destroy();
this.peers.delete(peerId);
}
}
closeAll(): void {
for (const [id, peer] of this.peers) {
peer.destroy();
}
this.peers.clear();
}
}

68
web/src/lib/signaling.ts Normal file
View File

@ -0,0 +1,68 @@
import type { ClientMessage, ServerMessage } from "@anydrop/shared";
export type SignalingHandler = (msg: ServerMessage) => void;
const WS_URL = import.meta.env.VITE_WS_URL || `ws://${window.location.hostname}:3001`;
export class SignalingClient {
private ws: WebSocket | null = null;
private handler: SignalingHandler;
private joinCode?: string;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionalClose = false;
constructor(handler: SignalingHandler, joinCode?: string) {
this.handler = handler;
this.joinCode = joinCode;
}
connect(): void {
this.intentionalClose = false;
this.ws = new WebSocket(WS_URL);
this.ws.onopen = () => {
this.send({ type: "hello", joinCode: this.joinCode });
};
this.ws.onmessage = (event) => {
try {
const msg: ServerMessage = JSON.parse(event.data);
this.handler(msg);
} catch {
// Ignore malformed messages
}
};
this.ws.onclose = () => {
if (!this.intentionalClose) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
this.ws?.close();
};
}
send(msg: ClientMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
disconnect(): void {
this.intentionalClose = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
}
private scheduleReconnect(): void {
this.reconnectTimer = setTimeout(() => {
this.connect();
}, 2000);
}
}

13
web/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

149
web/src/pages/Home.tsx Normal file
View File

@ -0,0 +1,149 @@
import { useCallback } from "react";
import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore";
import PeerList from "../components/PeerList";
import DropZone from "../components/DropZone";
import TransferProgress from "../components/TransferProgress";
import TextShareModal from "../components/TextShareModal";
import ReceiveDialog from "../components/ReceiveDialog";
import PublicRoomPanel from "../components/PublicRoomPanel";
export default function Home() {
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom } =
useSignaling();
const displayName = useStore((s) => s.displayName);
const selectedPeerId = useStore((s) => s.selectedPeerId);
const showTextModal = useStore((s) => s.showTextModal);
const incomingRequest = useStore((s) => s.incomingRequest);
const error = useStore((s) => s.error);
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
const setShowTextModal = useStore((s) => s.setShowTextModal);
const setError = useStore((s) => s.setError);
const handlePeerSelect = useCallback(
(peerId: string) => {
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
},
[selectedPeerId, setSelectedPeerId],
);
const handleFilesSelected = useCallback(
(files: File[]) => {
if (!selectedPeerId) return;
sendFiles(selectedPeerId, files);
},
[selectedPeerId, sendFiles],
);
const handleSendText = useCallback(
(text: string) => {
if (!selectedPeerId) return;
sendText(selectedPeerId, text);
},
[selectedPeerId, sendText],
);
const handleAcceptTransfer = useCallback(() => {
if (incomingRequest) {
if (incomingRequest.text && incomingRequest.files.length === 0) {
navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
useStore.getState().setIncomingRequest(null);
} else {
acceptTransfer(incomingRequest.peerId);
}
}
}, [incomingRequest, acceptTransfer]);
const handleRejectTransfer = useCallback(() => {
if (incomingRequest) {
rejectTransfer(incomingRequest.peerId);
}
}, [incomingRequest, rejectTransfer]);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-lg mx-auto px-4 py-8">
{/* Header */}
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
{displayName && (
<p className="mt-3 text-sm text-slate-400">
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span>
</p>
)}
</header>
{/* Error banner */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
</button>
</div>
)}
{/* Peer list */}
<section className="mb-6">
<PeerList onPeerSelect={handlePeerSelect} />
</section>
{/* Drop zone */}
<section className="mb-6">
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
</section>
{/* Text share button */}
{selectedPeerId && (
<section className="mb-6">
<button
onClick={() => setShowTextModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="text-lg">💬</span>
<span className="text-sm font-medium">Envoyer du texte</span>
</button>
</section>
)}
{/* Transfer progress */}
<section className="mb-6">
<TransferProgress />
</section>
{/* Public room */}
<section className="mb-6">
<PublicRoomPanel onCreateRoom={createPublicRoom} />
</section>
{/* 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>
</footer>
</div>
{/* Modals */}
{showTextModal && selectedPeerId && (
<TextShareModal
onSend={handleSendText}
onClose={() => setShowTextModal(false)}
/>
)}
{incomingRequest && (
<ReceiveDialog
request={incomingRequest}
onAccept={handleAcceptTransfer}
onReject={handleRejectTransfer}
/>
)}
</div>
);
}

153
web/src/pages/JoinRoom.tsx Normal file
View File

@ -0,0 +1,153 @@
import { useParams } from "react-router-dom";
import { useCallback } from "react";
import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore";
import PeerList from "../components/PeerList";
import DropZone from "../components/DropZone";
import TransferProgress from "../components/TransferProgress";
import TextShareModal from "../components/TextShareModal";
import ReceiveDialog from "../components/ReceiveDialog";
export default function JoinRoom() {
const { code } = useParams<{ code: string }>();
const { sendFiles, sendText, acceptTransfer, rejectTransfer } = useSignaling(code);
const displayName = useStore((s) => s.displayName);
const selectedPeerId = useStore((s) => s.selectedPeerId);
const showTextModal = useStore((s) => s.showTextModal);
const incomingRequest = useStore((s) => s.incomingRequest);
const error = useStore((s) => s.error);
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
const setShowTextModal = useStore((s) => s.setShowTextModal);
const setError = useStore((s) => s.setError);
const handlePeerSelect = useCallback(
(peerId: string) => {
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
},
[selectedPeerId, setSelectedPeerId],
);
const handleFilesSelected = useCallback(
(files: File[]) => {
if (!selectedPeerId) return;
sendFiles(selectedPeerId, files);
},
[selectedPeerId, sendFiles],
);
const handleSendText = useCallback(
(text: string) => {
if (!selectedPeerId) return;
sendText(selectedPeerId, text);
},
[selectedPeerId, sendText],
);
const handleAcceptTransfer = useCallback(() => {
if (incomingRequest) {
if (incomingRequest.text && incomingRequest.files.length === 0) {
navigator.clipboard.writeText(incomingRequest.text);
useStore.getState().setIncomingRequest(null);
} else {
acceptTransfer(incomingRequest.peerId);
}
}
}, [incomingRequest, acceptTransfer]);
const handleRejectTransfer = useCallback(() => {
if (incomingRequest) {
rejectTransfer(incomingRequest.peerId);
}
}, [incomingRequest, rejectTransfer]);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-lg mx-auto px-4 py-8">
{/* Header */}
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
<p className="text-slate-500 text-sm">
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
</p>
{displayName && (
<p className="mt-3 text-sm text-slate-400">
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span>
</p>
)}
</header>
{/* Error banner */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
</button>
</div>
)}
{/* Peer list */}
<section className="mb-6">
<PeerList onPeerSelect={handlePeerSelect} />
</section>
{/* Drop zone */}
<section className="mb-6">
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
</section>
{/* Text share button */}
{selectedPeerId && (
<section className="mb-6">
<button
onClick={() => setShowTextModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="text-lg">💬</span>
<span className="text-sm font-medium">Envoyer du texte</span>
</button>
</section>
)}
{/* Transfer progress */}
<section className="mb-6">
<TransferProgress />
</section>
{/* Back to home */}
<section className="text-center">
<a href="/" className="text-sm text-slate-500 hover:text-slate-300 transition-colors">
Retour à l'accueil
</a>
</section>
{/* 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>
</footer>
</div>
{/* Modals */}
{showTextModal && selectedPeerId && (
<TextShareModal
onSend={handleSendText}
onClose={() => setShowTextModal(false)}
/>
)}
{incomingRequest && (
<ReceiveDialog
request={incomingRequest}
onAccept={handleAcceptTransfer}
onReject={handleRejectTransfer}
/>
)}
</div>
);
}

126
web/src/stores/useStore.ts Normal file
View File

@ -0,0 +1,126 @@
import { create } from "zustand";
import type { PeerInfo } from "@anydrop/shared";
export interface TransferInfo {
id: string;
peerId: string;
fileName: string;
fileSize: number;
mime: string;
direction: "send" | "receive";
progress: number; // 0-1
status: "pending" | "transferring" | "done" | "error";
}
export interface IncomingRequest {
peerId: string;
displayName: string;
files: { id: string; name: string; size: number; mime: string }[];
text?: string;
}
interface AppState {
// Connection
connected: boolean;
peerId: string | null;
displayName: string | null;
roomId: string | null;
// Peers
peers: PeerInfo[];
// Public room
publicRoomCode: string | null;
publicRoomUrl: string | null;
publicRoomExpiresAt: string | null;
// Transfers
transfers: TransferInfo[];
incomingRequest: IncomingRequest | null;
// UI
showTextModal: boolean;
selectedPeerId: string | null;
error: string | null;
// Actions
setConnection: (peerId: string, displayName: string, roomId: string) => void;
setConnected: (connected: boolean) => void;
addPeer: (peer: PeerInfo) => void;
removePeer: (peerId: string) => void;
setPeers: (peers: PeerInfo[]) => void;
setPublicRoom: (code: string, url: string, expiresAt: string) => void;
clearPublicRoom: () => void;
addTransfer: (transfer: TransferInfo) => void;
updateTransfer: (id: string, updates: Partial<TransferInfo>) => void;
removeTransfer: (id: string) => void;
setIncomingRequest: (request: IncomingRequest | null) => void;
setShowTextModal: (show: boolean) => void;
setSelectedPeerId: (peerId: string | null) => void;
setError: (error: string | null) => void;
reset: () => void;
}
const initialState = {
connected: false,
peerId: null,
displayName: null,
roomId: null,
peers: [],
publicRoomCode: null,
publicRoomUrl: null,
publicRoomExpiresAt: null,
transfers: [],
incomingRequest: null,
showTextModal: false,
selectedPeerId: null,
error: null,
};
export const useStore = create<AppState>((set) => ({
...initialState,
setConnection: (peerId, displayName, roomId) =>
set({ peerId, displayName, roomId, connected: true }),
setConnected: (connected) => set({ connected }),
addPeer: (peer) =>
set((s) => ({
peers: s.peers.some((p) => p.peerId === peer.peerId)
? s.peers
: [...s.peers, peer],
})),
removePeer: (peerId) =>
set((s) => ({ peers: s.peers.filter((p) => p.peerId !== peerId) })),
setPeers: (peers) => set({ peers }),
setPublicRoom: (code, url, expiresAt) =>
set({ publicRoomCode: code, publicRoomUrl: url, publicRoomExpiresAt: expiresAt }),
clearPublicRoom: () =>
set({ publicRoomCode: null, publicRoomUrl: null, publicRoomExpiresAt: null }),
addTransfer: (transfer) =>
set((s) => ({ transfers: [...s.transfers, transfer] })),
updateTransfer: (id, updates) =>
set((s) => ({
transfers: s.transfers.map((t) => (t.id === id ? { ...t, ...updates } : t)),
})),
removeTransfer: (id) =>
set((s) => ({ transfers: s.transfers.filter((t) => t.id !== id) })),
setIncomingRequest: (request) => set({ incomingRequest: request }),
setShowTextModal: (show) => set({ showTextModal: show }),
setSelectedPeerId: (peerId) => set({ selectedPeerId: peerId }),
setError: (error) => set({ error }),
reset: () => set(initialState),
}));

24
web/tailwind.config.js Normal file
View File

@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
brand: {
50: "#eef2ff",
100: "#e0e7ff",
200: "#c7d2fe",
300: "#a5b4fc",
400: "#818cf8",
500: "#6366f1",
600: "#4f46e5",
700: "#4338ca",
800: "#3730a3",
900: "#312e81",
},
},
},
},
plugins: [],
};

14
web/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"jsx": "react-jsx",
"types": ["vite/client"],
"declaration": false,
"declarationMap": false
},
"include": ["src"],
"references": [
{ "path": "../shared" }
]
}

45
web/vite.config.ts Normal file
View File

@ -0,0 +1,45 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import { nodePolyfills } from "vite-plugin-node-polyfills";
export default defineConfig({
define: {
global: "globalThis",
},
plugins: [
react(),
nodePolyfills({
include: ["process", "events", "stream", "buffer", "util"],
}),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.svg"],
manifest: {
name: "AnyDrop",
short_name: "AnyDrop",
description: "Partage de fichiers instantané, peer-to-peer, sans compte",
theme_color: "#6366f1",
background_color: "#0f172a",
display: "standalone",
start_url: "/",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
},
],
},
}),
],
server: {
port: 5173,
host: true,
},
});