更新compose配置文件

This commit is contained in:
619dev
2026-03-28 22:41:12 +08:00
parent 10b9e531fc
commit 5e8b3b86ec
12 changed files with 748 additions and 1642 deletions

View File

@@ -10,367 +10,173 @@ Eine Instant-Messaging-App im WeChat-Stil mit Ende-zu-Ende-Verschlüsselung übe
---
<img width=30% height=30% src="https://raw.githubusercontent.com/619dev/PaperPhone/main/ui.jpg" alt="ui">
## Features
| Feature | Description |
|---------|-------------|
| 🔐 End-to-End Encryption | Stateless ECDH + XSalsa20-Poly1305 — ephemeral keys per message, forward secrecy |
| 🗑️ Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| ⏱️ Auto-Delete Messages | 5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French, German, Russian, Spanish — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |
| 💬 Rich Messaging | Text, images, voice messages, 64-emoji panel, delivery receipts, typing indicators |
| 🌐 Moments | WeChat-style social feed: text + up to 9 photos, likes (friend avatars), comments, tag-based visibility control |
| 🏷️ Friend Tags | Assign multiple tags to friends (12-color preset palette), filter contacts by tag |
| 🗂️ R2 Object Storage | Cloudflare R2 for image/voice files — optional public CDN URL |
| 🏗 Self-Hostable | Docker Compose one-command deployment; Node.js + Redis multi-node ready |
## Funktionen
| Funktion | Beschreibung |
|----------|-------------|
| 🔐 E2E-Verschlüsselung | Zustandsloses ECDH + XSalsa20-Poly1305 — ephemere Schlüssel pro Nachricht, Forward Secrecy |
| 🗝️ Zero-Knowledge-Server | Server speichert nur Chiffretext; private Schlüssel verlassen niemals das Gerät |
| 📹 Video-/Sprachanrufe | WebRTC P2P (1:1) + Mesh (Gruppe), Cloudflare TURN für NAT-Traversal |
| 👥 Gruppenchat | Bis zu 2000 Mitglieder, Klartextnachrichten, Nicht-stören-Modus, Mitgliederverwaltung |
| ⏱️ Auto-Löschung | 5 Stufen (nie/1T/3T/1W/1M), in DMs beidseitig einstellbar, in Gruppen nur Inhaber |
| 🔔 Push-Benachrichtigungen | Web Push (VAPID) + OneSignal Dual-Kanal |
| 🌐 Mehrsprachig | ZH/EN/JA/KO/FR/DE/RU/ES — automatische Erkennung + manuelle Umschaltung |
| 📱 iOS ohne Unternehmenszertifikat | PWA über Safari „Zum Home-Bildschirm", funktioniert dauerhaft ohne Apple-Signatur |
| 💬 Rich Messaging | Text, Bilder, Sprachnachrichten, 64-Emoji-Panel, Lesebestätigungen |
| 🌐 Momente | Sozialer Feed: Text + bis zu 9 Fotos, Likes (Freunde-Avatare), Kommentare, Tag-basierte Sichtbarkeit |
| 🏷 Freunde-Tags | Mehrere Tags pro Freund (12-Farben-Palette), Kontakte nach Tags filtern |
| 🗂️ R2-Speicher | Cloudflare R2 für Bild-/Audiodateien — optionale CDN-URL |
| 🏗️ Self-Hosting | Docker Compose Ein-Befehl-Deployment |
---
## Tech Stack
## Technologie-Stack
```
Backend (server/)
Node.js 20 + Express + ws
MySQL 8.0 users, messages (persisted ciphertext)
Redis online presence + cross-node routing
Cloudflare R2 — image/voice file storage (S3-compatible API)
JWT + bcrypt authentication
MySQL 8.0 — Benutzer, Nachrichten (verschlüsselt)
Redis — Online-Status + Knotenübergreifendes Routing
Cloudflare R2 — Bild-/Audio-Dateispeicher (S3-kompatible API)
JWT + bcrypt Authentifizierung
Frontend (client/)
Native HTML + Vanilla JS (ESM, no bundler required)
Natives HTML + Vanilla JS (ESM, kein Bundler)
libsodium-wrappers (WebAssembly — Curve25519 / XSalsa20-Poly1305)
WebRTC API — video / voice calls
WebRTC API — Video-/Sprachanrufe
PWA: manifest.json + Service Worker
Cryptographic Layer
Stateless ECDH + XSalsa20-Poly1305 — ephemeral keypair per message
Four-tier key persistence: memory → localStorage → sessionStorage → IndexedDB
All private keys stored on-device only — never sent to the server
Kryptographieschicht
Zustandsloses ECDH + XSalsa20-Poly1305 — ephemeres Schlüsselpaar pro Nachricht
4-Stufen-Schlüsselpersistenz: Speicher → localStorage → sessionStorage → IndexedDB
Alle privaten Schlüssel nur auf dem Gerät — nie an den Server gesendet
```
---
## Quick Start
## Schnellstart
### Option 0: Zeabur One-Click Cloud Deploy
### Option 0: Zeabur Ein-Klick-Cloud-Deployment
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
> [!NOTE]
> One manual step is required after the template deploys, otherwise login/register won't work:
> 1. Go to Zeabur Console → **server service** → Environment Variablescopy the value of `ZEABUR_WEB_URL` (e.g. `http://10.43.x.x:3000`)
> 2. Go to **client service** → Environment Variables add variable `SERVER_URL` = the value copied above
> 3. Restart the client service
> Nach dem Deployment ist ein manueller Schritt erforderlich:
> 1. Zeabur-Konsole → **server**-Dienst → Umgebungsvariablen`ZEABUR_WEB_URL` kopieren
> 2. **client**-Dienst → Umgebungsvariablen → `SERVER_URL` = kopierter Wert
> 3. Client-Dienst neu starten
**Known notes:**
- On first startup, the server automatically creates all database tables (`CREATE TABLE IF NOT EXISTS`) — no manual SQL import needed
- Redis runs without a password inside the cluster (intra-cluster network isolation is sufficient)
- If MySQL access is denied, manually set `DB_PASS` on the server service to the value of `MYSQL_ROOT_PASSWORD` from the MySQL service
- To get the **internal IP** of any service container, open that service's Terminal in the Zeabur console and run:
```bash
hostname -i
```
---
### Option 1: Docker Compose (Recommended — no local build needed)
### Option 1: Docker Compose (empfohlen)
```bash
# Clone the repository
git clone <repo-url> && cd paperphone
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in: DB_PASS / JWT_SECRET / CF_CALLS_APP_ID etc.
# Pull images and start everything
# Variablen bearbeiten: DB_PASS / JWT_SECRET / R2_* usw.
docker compose up -d
# Check service status
docker compose ps
# Open in browser
open http://localhost
```
> Pre-built images on Docker Hub:
> - `facilisvelox/paperphone-client:latest`
> - `facilisvelox/paperphone-server:latest`
>
> **Note**: The server automatically initialises the database schema on first startup — no manual SQL import required.
> Docker-Hub-Images: `facilisvelox/paperphone-client:latest` und `facilisvelox/paperphone-server:latest`
### Option 2: Manual Local Start
#### 1. Prepare the environment
### Option 2: Lokaler manueller Start
```bash
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in DB_HOST / DB_PASS / REDIS_HOST / R2_* etc.
# Backend
cd server && npm install && npm run dev # → http://localhost:3000
# Note: the server auto-runs schema.sql on first startup
```
#### 2. Start the backend
```bash
cd server
npm install
npm run dev # → http://localhost:3000
```
#### 3. Start the frontend
```bash
npx serve client -p 8080
# → http://localhost:8080
# Frontend
npx serve client -p 8080 # → http://localhost:8080
```
---
## Video Call Configuration
Video and voice calls use WebRTC P2P and work out of the box on the same LAN. For cross-network calls, a TURN server is required for NAT traversal.
### Using Cloudflare TURN (Recommended)
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → create an App
2. Copy the **App ID** and **App Secret** (Token Key)
3. Add them to `server/.env`:
## Videoanruf-Konfiguration — Cloudflare TURN
```env
CF_CALLS_APP_ID=your_app_id_here
CF_CALLS_APP_SECRET=your_app_secret_here
```
4. Restart the backend — TURN credentials are fetched automatically per call session (TTL: 86 400 s)
| Typ | Transport | Empfohlen für |
|-----|-----------|---------------|
| 1:1 Video | WebRTC P2P + TURN | Alle Szenarien |
| 1:1 Sprache | WebRTC P2P + TURN | Alle Szenarien |
| Gruppenanruf | WebRTC Mesh | Bis zu 6 Teilnehmer |
> **Without credentials**: the server falls back to STUN only (Google + Cloudflare public STUN). Calls work on LAN without any extra configuration.
### Call Types
| Type | Transport | Recommended for |
|------|-----------|-----------------|
| 1:1 Video Call | WebRTC P2P + TURN | All scenarios |
| 1:1 Voice Call | WebRTC P2P + TURN | All scenarios |
| Group Video / Voice | WebRTC Mesh (full-mesh) | Up to 6 participants |
> **Ohne Konfiguration**: Nur STUN. LAN-Anrufe funktionieren ohne Einrichtung.
---
## Push Notification Configuration
## Push-Benachrichtigungen
Offline message notifications are delivered through **two channels** for maximum delivery rate:
| Channel | Platforms | Configuration |
|---------|-----------|---------------|
| Web Push (VAPID) | Browsers (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID keys |
| OneSignal | Native Android/iOS apps via Median.co | OneSignal App ID + REST Key |
### Configuring Web Push
1. Generate VAPID keys (one-time):
| Kanal | Plattformen | Konfiguration |
|-------|-------------|---------------|
| Web Push | Browser + iOS PWA (Safari 16.4+) | VAPID-Schlüssel |
| OneSignal | Native Apps über Median.co | App ID + REST Key |
```bash
cd server
npx web-push generate-vapid-keys
```
2. Add to `server/.env`:
---
```env
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:admin@your-domain.com
```
## iOS — Permanente Installation ohne Zertifikat
3. Restart the server — users can enable notifications from the Settings page
> **iOS users** must first "Add to Home Screen" (PWA), and only iOS 16.4+ is supported.
### Configuring OneSignal (Median.co Native Apps)
1. Create an app on [OneSignal Dashboard](https://onesignal.com) and configure Firebase
2. Enable OneSignal in Median.co and enter the App ID
3. Add the OneSignal **App ID** and **REST API Key** to `server/.env`:
```env
ONESIGNAL_APP_ID=your_onesignal_app_id
ONESIGNAL_REST_KEY=your_onesignal_rest_api_key
```
> **When not configured**: push notifications are silently disabled — all other features work normally.
1. Auf HTTPS-Server bereitstellen → 2. In Safari öffnen → 3. Teilen ⬆️ → 4. „Zum Home-Bildschirm"
---
## iOS — Permanent No-Cert Deployment
## Sicherheitsmodell
1. Deploy to a server with an HTTPS domain (required for WebRTC and Web Crypto APIs)
2. Open `https://your.domain.com` in **Safari**
3. Tap the Share button ⬆️ at the bottom of the screen
4. Select **Add to Home Screen** → **Add**
The app behaves identically to a native app — no Apple enterprise certificate required, no expiry.
---
## Production Deployment (Nginx)
```nginx
server {
listen 443 ssl http2;
server_name your.domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Frontend static files
location / {
root /path/to/paperphone/client;
try_files $uri /index.html;
}
# REST API
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
}
# WebSocket (messaging + call signalling)
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Registrierung: IK + SPK + 10×OPK lokal generiert, öffentliche Schlüssel hochgeladen
Nachricht: Ephemeres ECDH → X25519 → XSalsa20-Poly1305
Server sieht: ✅ Chiffretext + Routing-Metadaten ❌ Klartext / private Schlüssel
```
---
## Project Structure
## Datenbankschema
```
paperphone/
├── docker-compose.yml
├── server/
│ ├── .env # Environment variables (incl. Cloudflare TURN keys)
│ └── src/
│ ├── app.js # Express application
│ ├── routes/
│ │ ├── auth.js # Register / Login (incl. X3DH public key upload)
│ │ ├── users.js # User search / Prekey bundle download
│ │ ├── friends.js # Friend requests / Accept (incl. offline push)
│ │ ├── groups.js # Group management
│ │ ├── messages.js # Historical messages (paginated ciphertext)
│ │ ├── upload.js # Cloudflare R2 file upload
│ │ ├── files.js # File proxy (when R2_PUBLIC_URL is not set)
│ │ ├── moments.js # Moments feed (posts / likes / comments)
│ │ ├── calls.js # TURN credential issuance
│ │ └── push.js # Push subscription mgmt (Web Push + OneSignal)
│ ├── services/
│ │ ├── push.js # Web Push VAPID service
│ │ └── onesignal.js # OneSignal REST API service
│ └── ws/
│ └── wsServer.js # WebSocket router (messages + call signalling + offline push)
└── client/
├── index.html # SPA entry + PWA meta + Median push bridge
├── manifest.json # PWA manifest
├── sw.js # Service Worker (offline cache + push notifications)
└── src/
├── style.css # Premium design system (dark/light, glassmorphism)
├── app.js # Router + global state + incoming call listener
├── api.js # HTTP client
├── socket.js # WebSocket client (auto-reconnect)
├── i18n.js # Multi-language engine (zh / en / ja / ko / fr / de / ru / es)
├── services/
│ ├── webrtc.js # WebRTC manager — CallManager class
│ └── pushNotification.js # Push subscription mgmt (Web Push + Median bridge)
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # IndexedDB private key store
├── pages/
│ ├── login.js # Login / Register (key generation, language picker)
│ ├── chats.js # Chat list
│ ├── chat.js # Chat window (E2E encryption, call buttons)
│ ├── groups.js # Group list (create group, search)
│ ├── groupInfo.js # Group info (member management, DND, leave/disband)
│ ├── contacts.js # Contacts (friend requests, online status)
│ ├── discover.js # Discover page
│ ├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
│ └── call.js # Call UI (incoming / active / multi-party video)
```
11 Tabellen, automatisch beim ersten Start erstellt:
| Tabelle | Zweck |
|---------|-------|
| `users` | Benutzerprofile + ECDH/OPK-Schlüssel |
| `prekeys` | X3DH-Einmal-Prekeys |
| `friends` | Freundschaftsbeziehungen |
| `groups` / `group_members` | Gruppenchats + Mitglieder |
| `messages` | Verschlüsselte Nachrichten |
| `moments` / `moment_images` | Soziale Beiträge + Bilder |
| `moment_likes` / `moment_comments` | Likes + Kommentare |
| `push_subscriptions` | Web Push (VAPID) |
| `onesignal_players` | OneSignal-Geräte (Median.co) |
---
## Database Schema
## Umgebungsvariablen
11 tables, auto-created on first server startup (`CREATE TABLE IF NOT EXISTS`):
| Table | Purpose |
|-------|---------|
| `users` | User profiles + ECDH/OPK public keys |
| `prekeys` | X3DH one-time prekey pool |
| `friends` | Friendship relationships (pending / accepted / blocked) |
| `groups` / `group_members` | Group chats + membership (incl. mute/DND status) |
| `messages` | Encrypted payloads (offline buffer, deletable after delivery) |
| `moments` | Social posts (text ≤ 1024 chars) |
| `moment_images` | Post images (up to 9 per post) |
| `moment_likes` | Likes (unique per user per post) |
| `moment_comments` | Comments (≤ 512 chars each) |
| `push_subscriptions` | Web Push subscriptions (VAPID) |
| `onesignal_players` | OneSignal device registrations (Median.co) |
| Variable | Beschreibung | Standard |
|----------|-------------|----------|
| `PORT` | Server-Port | `3000` |
| `JWT_SECRET` | JWT-Signaturschlüssel (**in Produktion ändern**) | dev_secret |
| `DB_HOST`/`DB_PASS`/`DB_NAME` | MySQL-Verbindung | — |
| `REDIS_HOST`/`REDIS_PASS` | Redis-Verbindung | — |
| `R2_ACCOUNT_ID` | Cloudflare-Konto-ID | — |
| `R2_ACCESS_KEY_ID` | R2-API-Zugriffsschlüssel | — |
| `R2_SECRET_ACCESS_KEY` | R2-API-Geheimschlüssel | — |
| `R2_BUCKET` | R2-Bucket-Name | — |
| `R2_PUBLIC_URL` | Öffentliche R2-URL (optional) | — |
| `CF_CALLS_APP_ID` | Calls App ID (optional) | — |
| `CF_CALLS_APP_SECRET` | Calls Secret (optional) | — |
| `VAPID_PUBLIC_KEY` | VAPID öffentlicher Schlüssel (optional) | — |
| `VAPID_PRIVATE_KEY` | VAPID privater Schlüssel (optional) | — |
| `ONESIGNAL_APP_ID` | OneSignal App ID (optional) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST Key (optional) | — |
---
## Security Model
```
On Registration:
Device generates IK (Identity Key) + SPK (Signed PreKey) + 10× OPK (One-Time PreKeys)
Public keys are uploaded; private keys stay on-device (four-tier persistence)
On Each Message:
Sender fetches recipient's IK public key
Generates a fresh ephemeral ECDH keypair (per-message)
X25519 ECDH → shared secret → XSalsa20-Poly1305 encrypt
Ephemeral public key sent in message header; destroyed after use
What the Server Sees:
✅ Ciphertext blob + routing metadata (sender / recipient UUID, timestamps)
❌ Plaintext / private keys / ephemeral keys / call content
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `JWT_SECRET` | JWT signing key (**change in production**) | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL connection | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis connection | — |
| `R2_ACCOUNT_ID` | Cloudflare account ID | — |
| `R2_ACCESS_KEY_ID` | R2 API token access key | — |
| `R2_SECRET_ACCESS_KEY` | R2 API token secret key | — |
| `R2_BUCKET` | R2 bucket name | — |
| `R2_PUBLIC_URL` | R2 public base URL (optional) — enables direct CDN links | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID (optional) | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret (optional) | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID public key (optional) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID private key (optional) | — |
| `VAPID_SUBJECT` | VAPID contact email (optional) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID (optional, for Median.co) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key (optional) | — |
---
## License
## Lizenz
MIT © PaperPhone Contributors

View File

@@ -10,367 +10,173 @@ Una aplicación de mensajería instantánea cifrada de extremo a extremo estilo
---
<img width=30% height=30% src="https://raw.githubusercontent.com/619dev/PaperPhone/main/ui.jpg" alt="ui">
## Features
| Feature | Description |
## Características
| Función | Descripción |
|---------|-------------|
| 🔐 End-to-End Encryption | Stateless ECDH + XSalsa20-Poly1305 — ephemeral keys per message, forward secrecy |
| 🗑 Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| ⏱️ Auto-Delete Messages | 5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French, German, Russian, Spanish — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |
| 💬 Rich Messaging | Text, images, voice messages, 64-emoji panel, delivery receipts, typing indicators |
| 🌐 Moments | WeChat-style social feed: text + up to 9 photos, likes (friend avatars), comments, tag-based visibility control |
| 🏷️ Friend Tags | Assign multiple tags to friends (12-color preset palette), filter contacts by tag |
| 🗂️ R2 Object Storage | Cloudflare R2 for image/voice files — optional public CDN URL |
| 🏗️ Self-Hostable | Docker Compose one-command deployment; Node.js + Redis multi-node ready |
| 🔐 Cifrado E2E | ECDH sin estado + XSalsa20-Poly1305 — claves efímeras por mensaje, secreto hacia adelante |
| 🗝 Servidor de conocimiento cero | El servidor solo almacena texto cifrado; las claves privadas nunca salen del dispositivo |
| 📹 Videollamadas/voz | WebRTC P2P (1:1) + Mesh (grupo), Cloudflare TURN para atravesar NAT |
| 👥 Chat grupal | Hasta 2000 miembros, mensajes en texto plano, modo No molestar, gestión de miembros |
| ⏱️ Eliminación automática | 5 niveles (nunca/1d/3d/1sem/1mes), en DMs ambas partes, en grupos solo propietario |
| 🔔 Notificaciones push | Web Push (VAPID) + OneSignal canal doble |
| 🌐 Multilingüe | ZH/EN/JA/KO/FR/DE/RU/ES — detección automática + selección manual |
| 📱 iOS sin certificado empresarial | PWA vía Safari "Añadir a inicio", sin firma de Apple |
| 💬 Mensajería rica | Texto, imágenes, mensajes de voz, 64 emojis, confirmaciones de lectura |
| 🌐 Momentos | Feed social: texto + hasta 9 fotos, likes (avatares de amigos), comentarios, visibilidad por etiquetas |
| 🏷️ Etiquetas de amigos | Múltiples etiquetas por amigo (paleta de 12 colores), filtrar contactos por etiqueta |
| 🗂️ Almacenamiento R2 | Cloudflare R2 para imágenes/audio — URL CDN opcional |
| 🏗️ Auto-alojable | Despliegue Docker Compose en un comando |
---
## Tech Stack
## Stack tecnológico
```
Backend (server/)
Node.js 20 + Express + ws
MySQL 8.0 — users, messages (persisted ciphertext)
Redis — online presence + cross-node routing
Cloudflare R2 — image/voice file storage (S3-compatible API)
JWT + bcrypt authentication
MySQL 8.0 — usuarios, mensajes (texto cifrado)
Redis — presencia en línea + enrutamiento entre nodos
Cloudflare R2 — almacenamiento de archivos (API compatible S3)
Autenticación JWT + bcrypt
Frontend (client/)
Native HTML + Vanilla JS (ESM, no bundler required)
HTML nativo + Vanilla JS (ESM, sin bundler)
libsodium-wrappers (WebAssembly — Curve25519 / XSalsa20-Poly1305)
WebRTC API — video / voice calls
API WebRTC — videollamadas / llamadas de voz
PWA: manifest.json + Service Worker
Cryptographic Layer
Stateless ECDH + XSalsa20-Poly1305 — ephemeral keypair per message
Four-tier key persistence: memory → localStorage → sessionStorage → IndexedDB
All private keys stored on-device only — never sent to the server
Capa criptográfica
ECDH sin estado + XSalsa20-Poly1305 — par de claves efímeras por mensaje
Persistencia de claves en 4 niveles: memoria → localStorage → sessionStorage → IndexedDB
Todas las claves privadas solo en el dispositivo — nunca se envían al servidor
```
---
## Quick Start
## Inicio rápido
### Option 0: Zeabur One-Click Cloud Deploy
### Opción 0: Zeabur — Despliegue en la nube con un clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
> [!NOTE]
> One manual step is required after the template deploys, otherwise login/register won't work:
> 1. Go to Zeabur Console → **server service** → Environment Variables → copy the value of `ZEABUR_WEB_URL` (e.g. `http://10.43.x.x:3000`)
> 2. Go to **client service** → Environment Variables → add variable `SERVER_URL` = the value copied above
> 3. Restart the client service
> Se requiere un paso manual después del despliegue:
> 1. Consola Zeabur → servicio **server** → Variables de entorno → copiar el valor de `ZEABUR_WEB_URL`
> 2. Servicio **client** → Variables de entorno → agregar `SERVER_URL` = valor copiado
> 3. Reiniciar el servicio client
**Known notes:**
- On first startup, the server automatically creates all database tables (`CREATE TABLE IF NOT EXISTS`) — no manual SQL import needed
- Redis runs without a password inside the cluster (intra-cluster network isolation is sufficient)
- If MySQL access is denied, manually set `DB_PASS` on the server service to the value of `MYSQL_ROOT_PASSWORD` from the MySQL service
- To get the **internal IP** of any service container, open that service's Terminal in the Zeabur console and run:
```bash
hostname -i
```
---
### Option 1: Docker Compose (Recommended — no local build needed)
### Opción 1: Docker Compose (recomendado)
```bash
# Clone the repository
git clone <repo-url> && cd paperphone
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in: DB_PASS / JWT_SECRET / CF_CALLS_APP_ID etc.
# Pull images and start everything
# Editar: DB_PASS / JWT_SECRET / R2_* etc.
docker compose up -d
# Check service status
docker compose ps
# Open in browser
open http://localhost
```
> Pre-built images on Docker Hub:
> - `facilisvelox/paperphone-client:latest`
> - `facilisvelox/paperphone-server:latest`
>
> **Note**: The server automatically initialises the database schema on first startup — no manual SQL import required.
> Imágenes Docker Hub: `facilisvelox/paperphone-client:latest` y `facilisvelox/paperphone-server:latest`
### Option 2: Manual Local Start
#### 1. Prepare the environment
### Opción 2: Inicio local manual
```bash
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in DB_HOST / DB_PASS / REDIS_HOST / R2_* etc.
# Backend
cd server && npm install && npm run dev # → http://localhost:3000
# Note: the server auto-runs schema.sql on first startup
```
#### 2. Start the backend
```bash
cd server
npm install
npm run dev # → http://localhost:3000
```
#### 3. Start the frontend
```bash
npx serve client -p 8080
# → http://localhost:8080
# Frontend
npx serve client -p 8080 # → http://localhost:8080
```
---
## Video Call Configuration
Video and voice calls use WebRTC P2P and work out of the box on the same LAN. For cross-network calls, a TURN server is required for NAT traversal.
### Using Cloudflare TURN (Recommended)
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → create an App
2. Copy the **App ID** and **App Secret** (Token Key)
3. Add them to `server/.env`:
## Configuración de videollamadas — Cloudflare TURN
```env
CF_CALLS_APP_ID=your_app_id_here
CF_CALLS_APP_SECRET=your_app_secret_here
```
4. Restart the backend — TURN credentials are fetched automatically per call session (TTL: 86 400 s)
| Tipo | Transporte | Recomendado para |
|------|-----------|------------------|
| Video 1:1 | WebRTC P2P + TURN | Todos los escenarios |
| Voz 1:1 | WebRTC P2P + TURN | Todos los escenarios |
| Llamada grupal | WebRTC Mesh | Hasta 6 participantes |
> **Without credentials**: the server falls back to STUN only (Google + Cloudflare public STUN). Calls work on LAN without any extra configuration.
### Call Types
| Type | Transport | Recommended for |
|------|-----------|-----------------|
| 1:1 Video Call | WebRTC P2P + TURN | All scenarios |
| 1:1 Voice Call | WebRTC P2P + TURN | All scenarios |
| Group Video / Voice | WebRTC Mesh (full-mesh) | Up to 6 participants |
> **Sin configuración**: solo STUN. Las llamadas en LAN funcionan sin configuración adicional.
---
## Push Notification Configuration
## Notificaciones push
Offline message notifications are delivered through **two channels** for maximum delivery rate:
| Channel | Platforms | Configuration |
|---------|-----------|---------------|
| Web Push (VAPID) | Browsers (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID keys |
| OneSignal | Native Android/iOS apps via Median.co | OneSignal App ID + REST Key |
### Configuring Web Push
1. Generate VAPID keys (one-time):
| Canal | Plataformas | Configuración |
|-------|-------------|---------------|
| Web Push | Navegadores + iOS PWA (Safari 16.4+) | Claves VAPID |
| OneSignal | Apps nativas Median.co | App ID + REST Key |
```bash
cd server
npx web-push generate-vapid-keys
```
2. Add to `server/.env`:
---
```env
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:admin@your-domain.com
```
## iOS — Instalación permanente sin certificado
3. Restart the server — users can enable notifications from the Settings page
> **iOS users** must first "Add to Home Screen" (PWA), and only iOS 16.4+ is supported.
### Configuring OneSignal (Median.co Native Apps)
1. Create an app on [OneSignal Dashboard](https://onesignal.com) and configure Firebase
2. Enable OneSignal in Median.co and enter the App ID
3. Add the OneSignal **App ID** and **REST API Key** to `server/.env`:
```env
ONESIGNAL_APP_ID=your_onesignal_app_id
ONESIGNAL_REST_KEY=your_onesignal_rest_api_key
```
> **When not configured**: push notifications are silently disabled — all other features work normally.
1. Desplegar en servidor HTTPS → 2. Abrir en Safari → 3. Compartir ⬆️ → 4. «Añadir a pantalla de inicio»
---
## iOS — Permanent No-Cert Deployment
## Modelo de seguridad
1. Deploy to a server with an HTTPS domain (required for WebRTC and Web Crypto APIs)
2. Open `https://your.domain.com` in **Safari**
3. Tap the Share button ⬆️ at the bottom of the screen
4. Select **Add to Home Screen** → **Add**
The app behaves identically to a native app — no Apple enterprise certificate required, no expiry.
---
## Production Deployment (Nginx)
```nginx
server {
listen 443 ssl http2;
server_name your.domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Frontend static files
location / {
root /path/to/paperphone/client;
try_files $uri /index.html;
}
# REST API
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
}
# WebSocket (messaging + call signalling)
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Registro: IK + SPK + 10×OPK generados localmente, claves públicas subidas
Mensaje: ECDH efímero → X25519 → XSalsa20-Poly1305
El servidor ve: ✅ texto cifrado + metadatos de enrutamiento ❌ texto plano / claves privadas
```
---
## Project Structure
## Esquema de base de datos
```
paperphone/
├── docker-compose.yml
├── server/
│ ├── .env # Environment variables (incl. Cloudflare TURN keys)
│ └── src/
│ ├── app.js # Express application
│ ├── routes/
│ │ ├── auth.js # Register / Login (incl. X3DH public key upload)
│ │ ├── users.js # User search / Prekey bundle download
│ │ ├── friends.js # Friend requests / Accept (incl. offline push)
│ │ ├── groups.js # Group management
│ │ ├── messages.js # Historical messages (paginated ciphertext)
│ │ ├── upload.js # Cloudflare R2 file upload
│ │ ├── files.js # File proxy (when R2_PUBLIC_URL is not set)
│ │ ├── moments.js # Moments feed (posts / likes / comments)
│ │ ├── calls.js # TURN credential issuance
│ │ └── push.js # Push subscription mgmt (Web Push + OneSignal)
│ ├── services/
│ │ ├── push.js # Web Push VAPID service
│ │ └── onesignal.js # OneSignal REST API service
│ └── ws/
│ └── wsServer.js # WebSocket router (messages + call signalling + offline push)
└── client/
├── index.html # SPA entry + PWA meta + Median push bridge
├── manifest.json # PWA manifest
├── sw.js # Service Worker (offline cache + push notifications)
└── src/
├── style.css # Premium design system (dark/light, glassmorphism)
├── app.js # Router + global state + incoming call listener
├── api.js # HTTP client
├── socket.js # WebSocket client (auto-reconnect)
├── i18n.js # Multi-language engine (zh / en / ja / ko / fr / de / ru / es)
├── services/
│ ├── webrtc.js # WebRTC manager — CallManager class
│ └── pushNotification.js # Push subscription mgmt (Web Push + Median bridge)
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # IndexedDB private key store
├── pages/
│ ├── login.js # Login / Register (key generation, language picker)
│ ├── chats.js # Chat list
│ ├── chat.js # Chat window (E2E encryption, call buttons)
│ ├── groups.js # Group list (create group, search)
│ ├── groupInfo.js # Group info (member management, DND, leave/disband)
│ ├── contacts.js # Contacts (friend requests, online status)
│ ├── discover.js # Discover page
│ ├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
│ └── call.js # Call UI (incoming / active / multi-party video)
```
11 tablas, creadas automáticamente en el primer inicio:
| Tabla | Propósito |
|-------|-----------|
| `users` | Perfiles + claves públicas ECDH/OPK |
| `prekeys` | Pre-claves X3DH de un solo uso |
| `friends` | Relaciones de amistad |
| `groups` / `group_members` | Grupos + miembros |
| `messages` | Mensajes cifrados |
| `moments` / `moment_images` | Publicaciones + imágenes |
| `moment_likes` / `moment_comments` | Likes + comentarios |
| `push_subscriptions` | Web Push (VAPID) |
| `onesignal_players` | Dispositivos OneSignal |
---
## Database Schema
## Variables de entorno
11 tables, auto-created on first server startup (`CREATE TABLE IF NOT EXISTS`):
| Table | Purpose |
|-------|---------|
| `users` | User profiles + ECDH/OPK public keys |
| `prekeys` | X3DH one-time prekey pool |
| `friends` | Friendship relationships (pending / accepted / blocked) |
| `groups` / `group_members` | Group chats + membership (incl. mute/DND status) |
| `messages` | Encrypted payloads (offline buffer, deletable after delivery) |
| `moments` | Social posts (text ≤ 1024 chars) |
| `moment_images` | Post images (up to 9 per post) |
| `moment_likes` | Likes (unique per user per post) |
| `moment_comments` | Comments (≤ 512 chars each) |
| `push_subscriptions` | Web Push subscriptions (VAPID) |
| `onesignal_players` | OneSignal device registrations (Median.co) |
| Variable | Descripción | Predeterminado |
|----------|-------------|----------------|
| `PORT` | Puerto del servidor | `3000` |
| `JWT_SECRET` | Clave de firma JWT (**cambiar en producción**) | dev_secret |
| `DB_HOST`/`DB_PASS`/`DB_NAME` | Conexión MySQL | — |
| `REDIS_HOST`/`REDIS_PASS` | Conexión Redis | — |
| `R2_ACCOUNT_ID` | ID de cuenta Cloudflare | — |
| `R2_ACCESS_KEY_ID` | Clave de acceso R2 API | — |
| `R2_SECRET_ACCESS_KEY` | Clave secreta R2 API | — |
| `R2_BUCKET` | Nombre del bucket R2 | — |
| `R2_PUBLIC_URL` | URL pública R2 (opc.) | — |
| `CF_CALLS_APP_ID` | Calls App ID (opc.) | — |
| `CF_CALLS_APP_SECRET` | Calls Secret (opc.) | — |
| `VAPID_PUBLIC_KEY` | Clave pública VAPID (opc.) | — |
| `VAPID_PRIVATE_KEY` | Clave privada VAPID (opc.) | — |
| `ONESIGNAL_APP_ID` | OneSignal App ID (opc.) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST Key (opc.) | — |
---
## Security Model
```
On Registration:
Device generates IK (Identity Key) + SPK (Signed PreKey) + 10× OPK (One-Time PreKeys)
Public keys are uploaded; private keys stay on-device (four-tier persistence)
On Each Message:
Sender fetches recipient's IK public key
Generates a fresh ephemeral ECDH keypair (per-message)
X25519 ECDH → shared secret → XSalsa20-Poly1305 encrypt
Ephemeral public key sent in message header; destroyed after use
What the Server Sees:
✅ Ciphertext blob + routing metadata (sender / recipient UUID, timestamps)
❌ Plaintext / private keys / ephemeral keys / call content
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `JWT_SECRET` | JWT signing key (**change in production**) | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL connection | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis connection | — |
| `R2_ACCOUNT_ID` | Cloudflare account ID | — |
| `R2_ACCESS_KEY_ID` | R2 API token access key | — |
| `R2_SECRET_ACCESS_KEY` | R2 API token secret key | — |
| `R2_BUCKET` | R2 bucket name | — |
| `R2_PUBLIC_URL` | R2 public base URL (optional) — enables direct CDN links | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID (optional) | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret (optional) | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID public key (optional) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID private key (optional) | — |
| `VAPID_SUBJECT` | VAPID contact email (optional) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID (optional, for Median.co) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key (optional) | — |
---
## License
## Licencia
MIT © PaperPhone Contributors

View File

@@ -10,367 +10,147 @@ Une application de messagerie instantanée chiffrée de bout en bout, style WeCh
---
<img width=30% height=30% src="https://raw.githubusercontent.com/619dev/PaperPhone/main/ui.jpg" alt="ui">
## Features
| Feature | Description |
|---------|-------------|
| 🔐 End-to-End Encryption | Stateless ECDH + XSalsa20-Poly1305 — ephemeral keys per message, forward secrecy |
| 🗑️ Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| ⏱️ Auto-Delete Messages | 5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French, German, Russian, Spanish — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |
| 💬 Rich Messaging | Text, images, voice messages, 64-emoji panel, delivery receipts, typing indicators |
| 🌐 Moments | WeChat-style social feed: text + up to 9 photos, likes (friend avatars), comments, tag-based visibility control |
| 🏷️ Friend Tags | Assign multiple tags to friends (12-color preset palette), filter contacts by tag |
| 🗂️ R2 Object Storage | Cloudflare R2 for image/voice files — optional public CDN URL |
| 🏗 Self-Hostable | Docker Compose one-command deployment; Node.js + Redis multi-node ready |
## Fonctionnalités
| Fonction | Description |
|----------|-------------|
| 🔐 Chiffrement E2E | ECDH sans état + XSalsa20-Poly1305 — clés éphémères par message, secret de transmission |
| 🗝️ Serveur à connaissance nulle | Le serveur ne stocke que le texte chiffré ; les clés privées ne quittent jamais l'appareil |
| 📹 Appels vidéo/audio | WebRTC P2P (1:1) + Mesh (groupe), Cloudflare TURN pour traversée NAT |
| 👥 Chat de groupe | Jusqu'à 2000 membres, messages en texte brut, mode Ne pas déranger |
| ⏱️ Suppression auto | 5 niveaux (jamais/1j/3j/1sem/1mois) |
| 🔔 Notifications push | Web Push (VAPID) + OneSignal double canal |
| 🌐 Multilingue | 中/EN/日/한/FR/DE/RU/ES — détection auto + sélection manuelle |
| 📱 iOS PWA | Safari « Ajouter à l'écran d'accueil », sans certificat Apple |
| 💬 Messagerie riche | Texte, images, audio, 64 émojis, accusés de réception |
| 🌐 Moments | Fil social : texte + 9 photos, likes (avatars), commentaires, visibilité par tags |
| 🏷 Tags d'amis | Plusieurs tags par ami (12 couleurs), filtrage des contacts |
| 🗂️ Stockage R2 | Cloudflare R2 pour images/audio — CDN optionnel |
| 🏗️ Auto-hébergeable | Docker Compose en une commande |
---
## Tech Stack
## Stack technique
```
Backend (server/)
Node.js 20 + Express + ws
MySQL 8.0 — users, messages (persisted ciphertext)
Redis — online presence + cross-node routing
Cloudflare R2 — image/voice file storage (S3-compatible API)
JWT + bcrypt authentication
MySQL 8.0 — utilisateurs, messages chiffrés
Redis présence en ligne + routage inter-nœuds
Cloudflare R2 — stockage fichiers (API S3)
JWT + bcrypt
Frontend (client/)
Native HTML + Vanilla JS (ESM, no bundler required)
libsodium-wrappers (WebAssembly — Curve25519 / XSalsa20-Poly1305)
WebRTC API — video / voice calls
PWA: manifest.json + Service Worker
HTML natif + Vanilla JS (ESM, sans bundler)
libsodium-wrappers (WebAssembly)
WebRTC API — appels vidéo/audio
PWA : manifest.json + Service Worker
Cryptographic Layer
Stateless ECDH + XSalsa20-Poly1305 — ephemeral keypair per message
Four-tier key persistence: memory → localStorage → sessionStorage → IndexedDB
All private keys stored on-device only — never sent to the server
Cryptographie
ECDH sans état + XSalsa20-Poly1305 — clés éphémères par message
Persistance 4 niveaux : mémoire → localStorage → sessionStorage → IndexedDB
Clés privées uniquement sur l'appareil
```
---
## Quick Start
## Démarrage rapide
### Option 0: Zeabur One-Click Cloud Deploy
### Option 0 : Zeabur en un clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
> [!NOTE]
> One manual step is required after the template deploys, otherwise login/register won't work:
> 1. Go to Zeabur Console → **server service** → Environment Variables → copy the value of `ZEABUR_WEB_URL` (e.g. `http://10.43.x.x:3000`)
> 2. Go to **client service** → Environment Variables → add variable `SERVER_URL` = the value copied above
> 3. Restart the client service
> Étape manuelle requise après déploiement :
> 1. Console Zeabur → **server** → Variables → copier `ZEABUR_WEB_URL`
> 2. **client** → Variables → ajouter `SERVER_URL` = valeur copiée
> 3. Redémarrer le client
**Known notes:**
- On first startup, the server automatically creates all database tables (`CREATE TABLE IF NOT EXISTS`) — no manual SQL import needed
- Redis runs without a password inside the cluster (intra-cluster network isolation is sufficient)
- If MySQL access is denied, manually set `DB_PASS` on the server service to the value of `MYSQL_ROOT_PASSWORD` from the MySQL service
- To get the **internal IP** of any service container, open that service's Terminal in the Zeabur console and run:
```bash
hostname -i
```
---
### Option 1: Docker Compose (Recommended — no local build needed)
### Option 1 : Docker Compose (recommandé)
```bash
# Clone the repository
git clone <repo-url> && cd paperphone
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in: DB_PASS / JWT_SECRET / CF_CALLS_APP_ID etc.
# Pull images and start everything
# Éditer les variables
docker compose up -d
# Check service status
docker compose ps
# Open in browser
open http://localhost
```
> Pre-built images on Docker Hub:
> - `facilisvelox/paperphone-client:latest`
> - `facilisvelox/paperphone-server:latest`
>
> **Note**: The server automatically initialises the database schema on first startup — no manual SQL import required.
### Option 2: Manual Local Start
#### 1. Prepare the environment
### Option 2 : Démarrage local
```bash
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in DB_HOST / DB_PASS / REDIS_HOST / R2_* etc.
# Backend
cd server && npm install && npm run dev
# Note: the server auto-runs schema.sql on first startup
```
#### 2. Start the backend
```bash
cd server
npm install
npm run dev # → http://localhost:3000
```
#### 3. Start the frontend
```bash
# Frontend
npx serve client -p 8080
# → http://localhost:8080
```
---
## Video Call Configuration
Video and voice calls use WebRTC P2P and work out of the box on the same LAN. For cross-network calls, a TURN server is required for NAT traversal.
### Using Cloudflare TURN (Recommended)
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → create an App
2. Copy the **App ID** and **App Secret** (Token Key)
3. Add them to `server/.env`:
## Appels vidéo — Cloudflare TURN
```env
CF_CALLS_APP_ID=your_app_id_here
CF_CALLS_APP_SECRET=your_app_secret_here
```
4. Restart the backend — TURN credentials are fetched automatically per call session (TTL: 86 400 s)
> **Without credentials**: the server falls back to STUN only (Google + Cloudflare public STUN). Calls work on LAN without any extra configuration.
### Call Types
| Type | Transport | Recommended for |
|------|-----------|-----------------|
| 1:1 Video Call | WebRTC P2P + TURN | All scenarios |
| 1:1 Voice Call | WebRTC P2P + TURN | All scenarios |
| Group Video / Voice | WebRTC Mesh (full-mesh) | Up to 6 participants |
> Sans configuration : STUN uniquement, appels LAN fonctionnent.
---
## Push Notification Configuration
## Notifications push
Offline message notifications are delivered through **two channels** for maximum delivery rate:
| Channel | Platforms | Configuration |
|---------|-----------|---------------|
| Web Push (VAPID) | Browsers (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID keys |
| OneSignal | Native Android/iOS apps via Median.co | OneSignal App ID + REST Key |
### Configuring Web Push
1. Generate VAPID keys (one-time):
| Canal | Plateformes | Configuration |
|-------|-------------|---------------|
| Web Push | Navigateurs + iOS PWA 16.4+ | Clés VAPID |
| OneSignal | Apps Median.co | App ID + REST Key |
```bash
cd server
npx web-push generate-vapid-keys
```
2. Add to `server/.env`:
---
```env
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:admin@your-domain.com
```
## iOS — Sans certificat
3. Restart the server — users can enable notifications from the Settings page
> **iOS users** must first "Add to Home Screen" (PWA), and only iOS 16.4+ is supported.
### Configuring OneSignal (Median.co Native Apps)
1. Create an app on [OneSignal Dashboard](https://onesignal.com) and configure Firebase
2. Enable OneSignal in Median.co and enter the App ID
3. Add the OneSignal **App ID** and **REST API Key** to `server/.env`:
```env
ONESIGNAL_APP_ID=your_onesignal_app_id
ONESIGNAL_REST_KEY=your_onesignal_rest_api_key
```
> **When not configured**: push notifications are silently disabled — all other features work normally.
1. Déployer avec HTTPS → 2. Ouvrir dans Safari → 3. Partager ⬆️ → 4. « Ajouter à l'écran d'accueil »
---
## iOS — Permanent No-Cert Deployment
## Sécurité
1. Deploy to a server with an HTTPS domain (required for WebRTC and Web Crypto APIs)
2. Open `https://your.domain.com` in **Safari**
3. Tap the Share button ⬆️ at the bottom of the screen
4. Select **Add to Home Screen** → **Add**
The app behaves identically to a native app — no Apple enterprise certificate required, no expiry.
---
## Production Deployment (Nginx)
```nginx
server {
listen 443 ssl http2;
server_name your.domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Frontend static files
location / {
root /path/to/paperphone/client;
try_files $uri /index.html;
}
# REST API
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
}
# WebSocket (messaging + call signalling)
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Inscription : IK + SPK + 10×OPK générés localement, clés publiques uploadées
Message : ECDH éphémère → X25519 → XSalsa20-Poly1305
Serveur voit : ✅ texte chiffré + métadonnées ❌ texte clair / clés privées
```
---
## Project Structure
## Variables d'environnement
```
paperphone/
├── docker-compose.yml
├── server/
│ ├── .env # Environment variables (incl. Cloudflare TURN keys)
│ └── src/
│ ├── app.js # Express application
│ ├── routes/
│ │ ├── auth.js # Register / Login (incl. X3DH public key upload)
│ │ ├── users.js # User search / Prekey bundle download
│ │ ├── friends.js # Friend requests / Accept (incl. offline push)
│ │ ├── groups.js # Group management
│ │ ├── messages.js # Historical messages (paginated ciphertext)
│ │ ├── upload.js # Cloudflare R2 file upload
│ │ ├── files.js # File proxy (when R2_PUBLIC_URL is not set)
│ │ ├── moments.js # Moments feed (posts / likes / comments)
│ │ ├── calls.js # TURN credential issuance
│ │ └── push.js # Push subscription mgmt (Web Push + OneSignal)
│ ├── services/
│ │ ├── push.js # Web Push VAPID service
│ │ └── onesignal.js # OneSignal REST API service
│ └── ws/
│ └── wsServer.js # WebSocket router (messages + call signalling + offline push)
└── client/
├── index.html # SPA entry + PWA meta + Median push bridge
├── manifest.json # PWA manifest
├── sw.js # Service Worker (offline cache + push notifications)
└── src/
├── style.css # Premium design system (dark/light, glassmorphism)
├── app.js # Router + global state + incoming call listener
├── api.js # HTTP client
├── socket.js # WebSocket client (auto-reconnect)
├── i18n.js # Multi-language engine (zh / en / ja / ko / fr / de / ru / es)
├── services/
│ ├── webrtc.js # WebRTC manager — CallManager class
│ └── pushNotification.js # Push subscription mgmt (Web Push + Median bridge)
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # IndexedDB private key store
├── pages/
│ ├── login.js # Login / Register (key generation, language picker)
│ ├── chats.js # Chat list
│ ├── chat.js # Chat window (E2E encryption, call buttons)
│ ├── groups.js # Group list (create group, search)
│ ├── groupInfo.js # Group info (member management, DND, leave/disband)
│ ├── contacts.js # Contacts (friend requests, online status)
│ ├── discover.js # Discover page
│ ├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
│ └── call.js # Call UI (incoming / active / multi-party video)
```
| Variable | Description | Défaut |
|----------|-------------|--------|
| `PORT` | Port serveur | `3000` |
| `JWT_SECRET` | Clé JWT (**changer en prod**) | dev_secret |
| `DB_HOST`/`DB_PASS`/`DB_NAME` | MySQL | — |
| `REDIS_HOST`/`REDIS_PASS` | Redis | — |
| `R2_ACCOUNT_ID` | Cloudflare ID | — |
| `R2_ACCESS_KEY_ID` | Clé d'accès R2 | — |
| `R2_SECRET_ACCESS_KEY` | Clé secrète R2 | — |
| `R2_BUCKET` | Nom du bucket | — |
| `R2_PUBLIC_URL` | URL publique (optionnel) | — |
| `CF_CALLS_APP_ID` | Calls App ID (optionnel) | — |
| `CF_CALLS_APP_SECRET` | Calls Secret (optionnel) | — |
| `VAPID_PUBLIC_KEY` | Clé publique VAPID (optionnel) | — |
| `VAPID_PRIVATE_KEY` | Clé privée VAPID (optionnel) | — |
| `ONESIGNAL_APP_ID` | OneSignal ID (optionnel) | — |
| `ONESIGNAL_REST_KEY` | OneSignal Key (optionnel) | — |
---
## Database Schema
11 tables, auto-created on first server startup (`CREATE TABLE IF NOT EXISTS`):
| Table | Purpose |
|-------|---------|
| `users` | User profiles + ECDH/OPK public keys |
| `prekeys` | X3DH one-time prekey pool |
| `friends` | Friendship relationships (pending / accepted / blocked) |
| `groups` / `group_members` | Group chats + membership (incl. mute/DND status) |
| `messages` | Encrypted payloads (offline buffer, deletable after delivery) |
| `moments` | Social posts (text ≤ 1024 chars) |
| `moment_images` | Post images (up to 9 per post) |
| `moment_likes` | Likes (unique per user per post) |
| `moment_comments` | Comments (≤ 512 chars each) |
| `push_subscriptions` | Web Push subscriptions (VAPID) |
| `onesignal_players` | OneSignal device registrations (Median.co) |
---
## Security Model
```
On Registration:
Device generates IK (Identity Key) + SPK (Signed PreKey) + 10× OPK (One-Time PreKeys)
Public keys are uploaded; private keys stay on-device (four-tier persistence)
On Each Message:
Sender fetches recipient's IK public key
Generates a fresh ephemeral ECDH keypair (per-message)
X25519 ECDH → shared secret → XSalsa20-Poly1305 encrypt
Ephemeral public key sent in message header; destroyed after use
What the Server Sees:
✅ Ciphertext blob + routing metadata (sender / recipient UUID, timestamps)
❌ Plaintext / private keys / ephemeral keys / call content
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `JWT_SECRET` | JWT signing key (**change in production**) | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL connection | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis connection | — |
| `R2_ACCOUNT_ID` | Cloudflare account ID | — |
| `R2_ACCESS_KEY_ID` | R2 API token access key | — |
| `R2_SECRET_ACCESS_KEY` | R2 API token secret key | — |
| `R2_BUCKET` | R2 bucket name | — |
| `R2_PUBLIC_URL` | R2 public base URL (optional) — enables direct CDN links | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID (optional) | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret (optional) | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID public key (optional) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID private key (optional) | — |
| `VAPID_SUBJECT` | VAPID contact email (optional) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID (optional, for Median.co) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key (optional) | — |
---
## License
## Licence
MIT © PaperPhone Contributors

View File

@@ -10,112 +10,113 @@ WeChatスタイルのエンドツーエンド暗号化インスタントメッ
---
<img width=30% height=30% src="https://raw.githubusercontent.com/619dev/PaperPhone/main/ui.jpg" alt="ui">
## Features
| Feature | Description |
|---------|-------------|
| 🔐 End-to-End Encryption | Stateless ECDH + XSalsa20-Poly1305 — ephemeral keys per message, forward secrecy |
| 🗑️ Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| ⏱️ Auto-Delete Messages | 5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French, German, Russian, Spanish — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |
| 💬 Rich Messaging | Text, images, voice messages, 64-emoji panel, delivery receipts, typing indicators |
| 🌐 Moments | WeChat-style social feed: text + up to 9 photos, likes (friend avatars), comments, tag-based visibility control |
| 🏷️ Friend Tags | Assign multiple tags to friends (12-color preset palette), filter contacts by tag |
| 🗂️ R2 Object Storage | Cloudflare R2 for image/voice files — optional public CDN URL |
| 🏗 Self-Hostable | Docker Compose one-command deployment; Node.js + Redis multi-node ready |
## 機能一覧
| 機能 | 説明 |
|------|------|
| 🔐 エンドツーエンド暗号化 | ステートレス ECDH + XSalsa20-Poly1305 — メッセージごとの一時鍵、前方秘匿性 |
| 🗝️ ゼロ知識サーバー | サーバーは暗号文のみ保存。秘密鍵はデバイスから離れません |
| 📹 ビデオ/音声通話 | WebRTC P2P1:1+ Meshグループ、Cloudflare TURN による NAT トラバーサル |
| 👥 グループチャット | 最大2000人、プレーンテキストメッセージ暗号化なし、通知オフモード、メンバー管理 |
| ⏱️ メッセージ自動削除 | 5段階なし/1日/3日/1週間/1ヶ月、DM は双方設定可、グループはオーナーのみ |
| 🔔 プッシュ通知 | Web Push (VAPID) + OneSignal デュアルチャネル — オフラインでも通知 |
| 🌐 多言語対応 | 中国語・英語・日本語・韓国語・フランス語・ドイツ語・ロシア語・スペイン語(自動検出+手動切替) |
| 📱 iOS 永久署名不要 | PWA — Safari「ホーム画面に追加」でエンタープライズ証明書なしで利用可能 |
| 💬 リッチメッセージ | テキスト・画像・音声・絵文字パネル64種・既読確認 |
| 🌐 モーメンツ | テキスト最大9枚写真、いいね友達アバター表示、コメント、タグベースの公開範囲制御 |
| 🏷 フレンドタグ | 友達に複数タグを設定12色プリセット、タグ別に連絡先をフィルタリング |
| 🗂️ R2 オブジェクトストレージ | Cloudflare R2 で画像/音声ファイルを保存 — オプションの CDN 直リンク |
| 🏗️ セルフホスト対応 | Docker Compose ワンコマンドデプロイ、Node.js + Redis マルチノード対応 |
---
## Tech Stack
## 技術スタック
```
Backend (server/)
バックエンド (server/)
Node.js 20 + Express + ws
MySQL 8.0 — users, messages (persisted ciphertext)
Redis — online presence + cross-node routing
Cloudflare R2 — image/voice file storage (S3-compatible API)
JWT + bcrypt authentication
MySQL 8.0 — ユーザー・メッセージの永続化
Redis — オンラインプレゼンス+クロスノードルーティング
Cloudflare R2 — 画像/音声ファイルストレージS3互換API
JWT + bcrypt 認証
Frontend (client/)
Native HTML + Vanilla JS (ESM, no bundler required)
libsodium-wrappers (WebAssembly — Curve25519 / XSalsa20-Poly1305)
WebRTC API — video / voice calls
フロントエンド (client/)
ネイティブ HTML + Vanilla JSESM、バンドラー不要)
libsodium-wrappersWebAssembly — Curve25519 / XSalsa20-Poly1305
WebRTC API — ビデオ/音声通話
PWA: manifest.json + Service Worker
Cryptographic Layer
Stateless ECDH + XSalsa20-Poly1305 — ephemeral keypair per message
Four-tier key persistence: memory → localStorage → sessionStorage → IndexedDB
All private keys stored on-device only — never sent to the server
暗号化レイヤー
ステートレス ECDH + XSalsa20-Poly1305 — メッセージごとの一時 ECDH 鍵ペア
秘密鍵4層永続化: メモリ → localStorage → sessionStorage → IndexedDB
秘密鍵はデバイスにのみ保存、サーバーに送信されることはありません
```
---
## Quick Start
## クイックスタート
### Option 0: Zeabur One-Click Cloud Deploy
### 方法0Zeabur ワンクリッククラウドデプロイ
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
> [!NOTE]
> One manual step is required after the template deploys, otherwise login/register won't work:
> 1. Go to Zeabur Console → **server service** → Environment Variables → copy the value of `ZEABUR_WEB_URL` (e.g. `http://10.43.x.x:3000`)
> 2. Go to **client service** → Environment Variables → add variable `SERVER_URL` = the value copied above
> 3. Restart the client service
> テンプレートのデプロイ後、1つの手動手順が必要です。これを行わないとログイン/登録が機能しません:
> 1. Zeabur コンソール → **server サービス** → 環境変数 → `ZEABUR_WEB_URL` の値をコピー(例:`http://10.43.x.x:3000`
> 2. **client サービス** → 環境変数 → 変数 `SERVER_URL` を追加 = 上記でコピーした値
> 3. client サービスを再起動
**Known notes:**
- On first startup, the server automatically creates all database tables (`CREATE TABLE IF NOT EXISTS`) — no manual SQL import needed
- Redis runs without a password inside the cluster (intra-cluster network isolation is sufficient)
- If MySQL access is denied, manually set `DB_PASS` on the server service to the value of `MYSQL_ROOT_PASSWORD` from the MySQL service
- To get the **internal IP** of any service container, open that service's Terminal in the Zeabur console and run:
**既知の注意事項:**
- 初回起動時、サーバーは自動的にすべてのデータベーステーブルを作成します(`CREATE TABLE IF NOT EXISTS`)— SQL の手動インポートは不要
- Redis はクラスター内でパスワードなしで動作します
- MySQL アクセスが拒否された場合、server サービスの `DB_PASS` を MySQL サービスの `MYSQL_ROOT_PASSWORD` に手動設定してください
- サービスコンテナの**内部IP**を取得するには、Zeabur コンソールで該当サービスのターミナルを開き、以下を実行:
```bash
hostname -i
```
---
### Option 1: Docker Compose (Recommended — no local build needed)
### 方法1Docker Compose(推奨 — ローカルビルド不要)
```bash
# Clone the repository
# リポジトリをクローン
git clone <repo-url> && cd paperphone
# Copy and edit environment variables
# 環境変数をコピーして編集
cp server/.env.example server/.env
# Fill in: DB_PASS / JWT_SECRET / CF_CALLS_APP_ID etc.
# DB_PASS / JWT_SECRET / R2_* などを編集
# Pull images and start everything
# イメージを取得して起動
docker compose up -d
# Check service status
# サービスステータスを確認
docker compose ps
# Open in browser
# ブラウザで開く
open http://localhost
```
> Pre-built images on Docker Hub:
> Docker Hub のビルド済みイメージ:
> - `facilisvelox/paperphone-client:latest`
> - `facilisvelox/paperphone-server:latest`
>
> **Note**: The server automatically initialises the database schema on first startup — no manual SQL import required.
> **注意**:サーバーは初回起動時に自動的にデータベーススキーマを初期化します — SQL の手動インポートは不要です。
### Option 2: Manual Local Start
### 方法2ローカル手動起動
#### 1. Prepare the environment
#### 1. 環境を準備
```bash
# Copy and edit environment variables
# 環境変数をコピーして編集
cp server/.env.example server/.env
# Fill in DB_HOST / DB_PASS / REDIS_HOST / R2_* etc.
# DB_HOST / DB_PASS / REDIS_HOST / R2_* などを入力
# Note: the server auto-runs schema.sql on first startup
# 注:サーバーは初回起動時に自動で schema.sql を実行します
```
#### 2. Start the backend
#### 2. バックエンドを起動
```bash
cd server
@@ -123,7 +124,7 @@ npm install
npm run dev # → http://localhost:3000
```
#### 3. Start the frontend
#### 3. フロントエンドを起動
```bash
npx serve client -p 8080
@@ -132,54 +133,54 @@ npx serve client -p 8080
---
## Video Call Configuration
## ビデオ通話の設定
Video and voice calls use WebRTC P2P and work out of the box on the same LAN. For cross-network calls, a TURN server is required for NAT traversal.
ビデオ通話と音声通話は WebRTC P2P を使用し、同一 LAN 内ではすぐに使えます。異なるネットワーク間の通話には、NAT トラバーサル用の TURN サーバーが必要です。
### Using Cloudflare TURN (Recommended)
### Cloudflare TURN の使用(推奨)
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → create an App
2. Copy the **App ID** and **App Secret** (Token Key)
3. Add them to `server/.env`:
1. [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → App を作成
2. **App ID** **App Secret**Token Key)をコピー
3. `server/.env` に追加:
```env
CF_CALLS_APP_ID=your_app_id_here
CF_CALLS_APP_SECRET=your_app_secret_here
```
4. Restart the backend — TURN credentials are fetched automatically per call session (TTL: 86 400 s)
4. バックエンドを再起動 — TURN クレデンシャルは通話セッションごとに自動取得されます(TTL: 86,400秒)
> **Without credentials**: the server falls back to STUN only (Google + Cloudflare public STUN). Calls work on LAN without any extra configuration.
> **未設定時**STUN のみにフォールバックしますGoogle + Cloudflare パブリック STUN。LAN 内の通話は追加設定なしで動作します。
### Call Types
### 通話タイプ
| Type | Transport | Recommended for |
|------|-----------|-----------------|
| 1:1 Video Call | WebRTC P2P + TURN | All scenarios |
| 1:1 Voice Call | WebRTC P2P + TURN | All scenarios |
| Group Video / Voice | WebRTC Mesh (full-mesh) | Up to 6 participants |
| タイプ | 通信方式 | 推奨用途 |
|--------|----------|----------|
| 1:1 ビデオ通話 | WebRTC P2P + TURN | すべてのシナリオ |
| 1:1 音声通話 | WebRTC P2P + TURN | すべてのシナリオ |
| グループ通話 | WebRTC Meshフルメッシュ | 最大6人 |
---
## Push Notification Configuration
## プッシュ通知の設定
Offline message notifications are delivered through **two channels** for maximum delivery rate:
オフラインメッセージ通知は**2つのチャネル**で配信され、配信率を最大化します:
| Channel | Platforms | Configuration |
|---------|-----------|---------------|
| Web Push (VAPID) | Browsers (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID keys |
| OneSignal | Native Android/iOS apps via Median.co | OneSignal App ID + REST Key |
| チャネル | 対応プラットフォーム | 設定 |
|----------|----------------------|------|
| Web Push (VAPID) | ブラウザ (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID キー |
| OneSignal | Median.co 経由のネイティブ Android/iOS アプリ | OneSignal App ID + REST Key |
### Configuring Web Push
### Web Push の設定
1. Generate VAPID keys (one-time):
1. VAPID キーを生成1回のみ
```bash
cd server
npx web-push generate-vapid-keys
```
2. Add to `server/.env`:
2. `server/.env` に追加:
```env
VAPID_PUBLIC_KEY=your_public_key_here
@@ -187,37 +188,37 @@ VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:admin@your-domain.com
```
3. Restart the server — users can enable notifications from the Settings page
3. サーバーを再起動 — ユーザーは設定ページから通知を有効にできます
> **iOS users** must first "Add to Home Screen" (PWA), and only iOS 16.4+ is supported.
> **iOS ユーザー**は先に「ホーム画面に追加」PWAを行う必要があり、iOS 16.4以上のみサポートされます。
### Configuring OneSignal (Median.co Native Apps)
### OneSignal の設定(Median.co ネイティブアプリ)
1. Create an app on [OneSignal Dashboard](https://onesignal.com) and configure Firebase
2. Enable OneSignal in Median.co and enter the App ID
3. Add the OneSignal **App ID** and **REST API Key** to `server/.env`:
1. [OneSignal Dashboard](https://onesignal.com) でアプリを作成し、Firebase を設定
2. Median.co で OneSignal を有効にし、App ID を入力
3. OneSignal **App ID** **REST API Key** `server/.env` に追加:
```env
ONESIGNAL_APP_ID=your_onesignal_app_id
ONESIGNAL_REST_KEY=your_onesignal_rest_api_key
```
> **When not configured**: push notifications are silently disabled — all other features work normally.
> **未設定時**:プッシュ通知は自動的に無効化されます — 他の機能には影響しません。
---
## iOS — Permanent No-Cert Deployment
## iOS — 証明書不要の永続デプロイ
1. Deploy to a server with an HTTPS domain (required for WebRTC and Web Crypto APIs)
2. Open `https://your.domain.com` in **Safari**
3. Tap the Share button ⬆️ at the bottom of the screen
4. Select **Add to Home Screen** → **Add**
1. HTTPS ドメインのサーバーにデプロイ(WebRTC Web Crypto API には HTTPS が必要)
2. **Safari** で `https://your.domain.com` を開く
3. 画面下部の共有ボタン ⬆️ をタップ
4. **ホーム画面に追加** → **追加** を選択
The app behaves identically to a native app — no Apple enterprise certificate required, no expiry.
ネイティブアプリと同様に動作します — Apple エンタープライズ証明書不要、期限なし。
---
## Production Deployment (Nginx)
## 本番デプロイ(Nginx
```nginx
server {
@@ -227,7 +228,7 @@ server {
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Frontend static files
# フロントエンド静的ファイル
location / {
root /path/to/paperphone/client;
try_files $uri /index.html;
@@ -239,7 +240,7 @@ server {
proxy_set_header Host $host;
}
# WebSocket (messaging + call signalling)
# WebSocket(メッセージング+通話シグナリング)
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
@@ -252,125 +253,69 @@ server {
---
## Project Structure
## データベーススキーマ
11テーブル、サーバー初回起動時に自動作成`CREATE TABLE IF NOT EXISTS`
| テーブル | 用途 |
|----------|------|
| `users` | ユーザー情報 + ECDH/OPK 公開鍵 |
| `prekeys` | X3DH ワンタイムプリキープール |
| `friends` | 友達関係pending / accepted / blocked |
| `groups` / `group_members` | グループチャット+メンバー(通知オフ状態含む) |
| `messages` | 暗号化メッセージ(オフラインバッファ、配信後削除可能) |
| `moments` | ソーシャル投稿(テキスト ≤ 1024文字 |
| `moment_images` | 投稿画像1投稿最大9枚 |
| `moment_likes` | いいね(ユーザーごと投稿ごとにユニーク) |
| `moment_comments` | コメント(≤ 512文字/件) |
| `push_subscriptions` | Web Push サブスクリプションVAPID |
| `onesignal_players` | OneSignal デバイス登録Median.co |
---
## セキュリティモデル
```
paperphone/
├── docker-compose.yml
├── server/
│ ├── .env # Environment variables (incl. Cloudflare TURN keys)
│ └── src/
├── app.js # Express application
├── routes/
│ ├── auth.js # Register / Login (incl. X3DH public key upload)
│ ├── users.js # User search / Prekey bundle download
│ │ ├── friends.js # Friend requests / Accept (incl. offline push)
│ │ ├── groups.js # Group management
│ ├── messages.js # Historical messages (paginated ciphertext)
│ ├── upload.js # Cloudflare R2 file upload
│ │ ├── files.js # File proxy (when R2_PUBLIC_URL is not set)
│ │ ├── moments.js # Moments feed (posts / likes / comments)
│ │ ├── calls.js # TURN credential issuance
│ │ └── push.js # Push subscription mgmt (Web Push + OneSignal)
│ ├── services/
│ │ ├── push.js # Web Push VAPID service
│ │ └── onesignal.js # OneSignal REST API service
│ └── ws/
│ └── wsServer.js # WebSocket router (messages + call signalling + offline push)
└── client/
├── index.html # SPA entry + PWA meta + Median push bridge
├── manifest.json # PWA manifest
├── sw.js # Service Worker (offline cache + push notifications)
└── src/
├── style.css # Premium design system (dark/light, glassmorphism)
├── app.js # Router + global state + incoming call listener
├── api.js # HTTP client
├── socket.js # WebSocket client (auto-reconnect)
├── i18n.js # Multi-language engine (zh / en / ja / ko / fr / de / ru / es)
├── services/
│ ├── webrtc.js # WebRTC manager — CallManager class
│ └── pushNotification.js # Push subscription mgmt (Web Push + Median bridge)
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # IndexedDB private key store
├── pages/
│ ├── login.js # Login / Register (key generation, language picker)
│ ├── chats.js # Chat list
│ ├── chat.js # Chat window (E2E encryption, call buttons)
│ ├── groups.js # Group list (create group, search)
│ ├── groupInfo.js # Group info (member management, DND, leave/disband)
│ ├── contacts.js # Contacts (friend requests, online status)
│ ├── discover.js # Discover page
│ ├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
│ └── call.js # Call UI (incoming / active / multi-party video)
登録時:
デバイスが IKアイデンティティキー+ SPK署名付きプリキー+ 10× OPKワンタイムプリキーを生成
公開鍵はアップロード、秘密鍵はデバイスに保存4層永続化
メッセージ送信時:
送信者が受信者の IK 公開鍵を取得
一時 ECDH 鍵ペアを生成(メッセージごとに新しいペア)
X25519 ECDH → 共有秘密 → XSalsa20-Poly1305 暗号化
一時公開鍵はメッセージヘッダーで送信、使用後に破棄
サーバーが見るもの:
✅ 暗号文ブロブルーティングメタデータ送受信者UUID、タイムスタンプ
❌ 平文 / 秘密鍵 / 一時鍵 / 通話内容
```
---
## Database Schema
## 環境変数リファレンス
11 tables, auto-created on first server startup (`CREATE TABLE IF NOT EXISTS`):
| Table | Purpose |
|-------|---------|
| `users` | User profiles + ECDH/OPK public keys |
| `prekeys` | X3DH one-time prekey pool |
| `friends` | Friendship relationships (pending / accepted / blocked) |
| `groups` / `group_members` | Group chats + membership (incl. mute/DND status) |
| `messages` | Encrypted payloads (offline buffer, deletable after delivery) |
| `moments` | Social posts (text ≤ 1024 chars) |
| `moment_images` | Post images (up to 9 per post) |
| `moment_likes` | Likes (unique per user per post) |
| `moment_comments` | Comments (≤ 512 chars each) |
| `push_subscriptions` | Web Push subscriptions (VAPID) |
| `onesignal_players` | OneSignal device registrations (Median.co) |
| 変数 | 説明 | デフォルト |
|------|------|------------|
| `PORT` | サーバーポート | `3000` |
| `JWT_SECRET` | JWT 署名キー(**本番環境では必ず変更** | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL 接続設定 | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis 接続設定 | — |
| `R2_ACCOUNT_ID` | Cloudflare アカウント ID | — |
| `R2_ACCESS_KEY_ID` | R2 API トークンのアクセスキー | — |
| `R2_SECRET_ACCESS_KEY` | R2 API トークンのシークレットキー | — |
| `R2_BUCKET` | R2 バケット名 | — |
| `R2_PUBLIC_URL` | R2 公開 URL任意— CDN 直リンクを有効化 | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID任意 | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret任意 | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID 公開鍵(任意) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID 秘密鍵(任意) | — |
| `VAPID_SUBJECT` | VAPID 連絡先メール(任意) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID任意、Median.co用 | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key任意 | — |
---
## Security Model
```
On Registration:
Device generates IK (Identity Key) + SPK (Signed PreKey) + 10× OPK (One-Time PreKeys)
Public keys are uploaded; private keys stay on-device (four-tier persistence)
On Each Message:
Sender fetches recipient's IK public key
Generates a fresh ephemeral ECDH keypair (per-message)
X25519 ECDH → shared secret → XSalsa20-Poly1305 encrypt
Ephemeral public key sent in message header; destroyed after use
What the Server Sees:
✅ Ciphertext blob + routing metadata (sender / recipient UUID, timestamps)
❌ Plaintext / private keys / ephemeral keys / call content
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `JWT_SECRET` | JWT signing key (**change in production**) | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL connection | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis connection | — |
| `R2_ACCOUNT_ID` | Cloudflare account ID | — |
| `R2_ACCESS_KEY_ID` | R2 API token access key | — |
| `R2_SECRET_ACCESS_KEY` | R2 API token secret key | — |
| `R2_BUCKET` | R2 bucket name | — |
| `R2_PUBLIC_URL` | R2 public base URL (optional) — enables direct CDN links | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID (optional) | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret (optional) | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID public key (optional) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID private key (optional) | — |
| `VAPID_SUBJECT` | VAPID contact email (optional) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID (optional, for Median.co) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key (optional) | — |
---
## License
## ライセンス
MIT © PaperPhone Contributors

View File

@@ -10,112 +10,113 @@ WeChat 스타일의 종단간 암호화 인스턴트 메시징 앱. 무상태 EC
---
<img width=30% height=30% src="https://raw.githubusercontent.com/619dev/PaperPhone/main/ui.jpg" alt="ui">
## Features
| Feature | Description |
|---------|-------------|
| 🔐 End-to-End Encryption | Stateless ECDH + XSalsa20-Poly1305 — ephemeral keys per message, forward secrecy |
| 🗑️ Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| ⏱️ Auto-Delete Messages | 5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French, German, Russian, Spanish — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |
| 💬 Rich Messaging | Text, images, voice messages, 64-emoji panel, delivery receipts, typing indicators |
| 🌐 Moments | WeChat-style social feed: text + up to 9 photos, likes (friend avatars), comments, tag-based visibility control |
| 🏷️ Friend Tags | Assign multiple tags to friends (12-color preset palette), filter contacts by tag |
| 🗂️ R2 Object Storage | Cloudflare R2 for image/voice files — optional public CDN URL |
| 🏗 Self-Hostable | Docker Compose one-command deployment; Node.js + Redis multi-node ready |
## 기능
| 기능 | 설명 |
|------|------|
| 🔐 종단간 암호화 | 무상태 ECDH + XSalsa20-Poly1305 — 메시지별 임시 키, 전방 비밀성 |
| 🗝️ 제로 지식 서버 | 서버는 암호문만 저장, 개인 키는 기기를 떠나지 않음 |
| 📹 영상/음성 통화 | WebRTC P2P (1:1) + Mesh (그룹), Cloudflare TURN을 통한 NAT 트래버설 |
| 👥 그룹 채팅 | 최대 2000명, 일반 텍스트 메시지 (비암호화), 방해 금지 모드, 멤버 관리 |
| ⏱️ 메시지 자동 삭제 | 5단계 (안함/1일/3일/1주/1개월), DM에서 양쪽 설정 가능, 그룹은 방장만 |
| 🔔 알림 | Web Push (VAPID) + OneSignal 이중 채널 — 오프라인에서도 알림 수신 |
| 🌐 다국어 | 중국어·영어·일본어·한국어·프랑스어·독일어·러시아어·스페인어 (자동 감지 + 수동 전환) |
| 📱 iOS — 기업 인증서 불필요 | Safari "홈 화면에 추가"를 통한 PWA, Apple 서명 없이 영구 작동 |
| 💬 풍부한 메시징 | 텍스트, 이미지, 음성 메시지, 이모지 패널 (64종), 읽음 확인 |
| 🌐 모먼트 | 텍스트 + 최대 9장 사진, 좋아요 (친구 아바타 표시), 댓글, 태그 기반 공개 범위 제어 |
| 🏷 친구 태그 | 친구에게 여러 태그 할당 (12색 프리셋), 태그별 연락처 필터링 |
| 🗂️ R2 오브젝트 스토리지 | Cloudflare R2로 이미지/음성 파일 저장 — 선택적 공개 CDN URL |
| 🏗️ 셀프 호스팅 가능 | Docker Compose 원커맨드 배포, Node.js + Redis 멀티 노드 지원 |
---
## Tech Stack
## 기술 스택
```
Backend (server/)
백엔드 (server/)
Node.js 20 + Express + ws
MySQL 8.0 — users, messages (persisted ciphertext)
Redis — online presence + cross-node routing
Cloudflare R2 — image/voice file storage (S3-compatible API)
JWT + bcrypt authentication
MySQL 8.0 — 사용자, 메시지 영속화 (암호문)
Redis — 온라인 상태 + 크로스 노드 라우팅
Cloudflare R2 — 이미지/음성 파일 저장소 (S3 호환 API)
JWT + bcrypt 인증
Frontend (client/)
Native HTML + Vanilla JS (ESM, no bundler required)
프론트엔드 (client/)
네이티브 HTML + Vanilla JS (ESM, 번들러 불필요)
libsodium-wrappers (WebAssembly — Curve25519 / XSalsa20-Poly1305)
WebRTC API — video / voice calls
WebRTC API — 영상/음성 통화
PWA: manifest.json + Service Worker
Cryptographic Layer
Stateless ECDH + XSalsa20-Poly1305 — ephemeral keypair per message
Four-tier key persistence: memory → localStorage → sessionStorage → IndexedDB
All private keys stored on-device only — never sent to the server
암호화 레이어
무상태 ECDH + XSalsa20-Poly1305 — 메시지별 임시 ECDH 키페어
개인 키 4단계 영속화: 메모리 → localStorage → sessionStorage → IndexedDB
모든 개인 키는 기기에만 저장 — 절대 서버로 전송되지 않음
```
---
## Quick Start
## 빠른 시작
### Option 0: Zeabur One-Click Cloud Deploy
### 방법 0: Zeabur 원클릭 클라우드 배포
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
> [!NOTE]
> One manual step is required after the template deploys, otherwise login/register won't work:
> 1. Go to Zeabur Console → **server service** → Environment Variables → copy the value of `ZEABUR_WEB_URL` (e.g. `http://10.43.x.x:3000`)
> 2. Go to **client service** → Environment Variables → add variable `SERVER_URL` = the value copied above
> 3. Restart the client service
> 템플릿 배포 후 수동 단계 하나가 필요합니다. 이 작업을 하지 않으면 로그인/회원가입이 작동하지 않습니다:
> 1. Zeabur 콘솔 → **server 서비스** → 환경 변수 → `ZEABUR_WEB_URL` 값 복사 (예: `http://10.43.x.x:3000`)
> 2. **client 서비스** → 환경 변수 → 변수 `SERVER_URL` 추가 = 위에서 복사한 값
> 3. client 서비스 재시작
**Known notes:**
- On first startup, the server automatically creates all database tables (`CREATE TABLE IF NOT EXISTS`) — no manual SQL import needed
- Redis runs without a password inside the cluster (intra-cluster network isolation is sufficient)
- If MySQL access is denied, manually set `DB_PASS` on the server service to the value of `MYSQL_ROOT_PASSWORD` from the MySQL service
- To get the **internal IP** of any service container, open that service's Terminal in the Zeabur console and run:
**알려진 참고 사항:**
- 첫 시작 시 서버가 자동으로 모든 데이터베이스 테이블을 생성합니다 (`CREATE TABLE IF NOT EXISTS`) — SQL 수동 임포트 불필요
- Redis는 클러스터 내에서 비밀번호 없이 작동합니다
- MySQL 접근이 거부되면 server 서비스의 `DB_PASS`를 MySQL 서비스의 `MYSQL_ROOT_PASSWORD` 값으로 수동 설정하세요
- 서비스 컨테이너의 **내부 IP**를 확인하려면 Zeabur 콘솔에서 해당 서비스의 터미널을 열고 실행:
```bash
hostname -i
```
---
### Option 1: Docker Compose (Recommended — no local build needed)
### 방법 1: Docker Compose (권장 — 로컬 빌드 불필요)
```bash
# Clone the repository
# 저장소 클론
git clone <repo-url> && cd paperphone
# Copy and edit environment variables
# 환경 변수 복사 및 편집
cp server/.env.example server/.env
# Fill in: DB_PASS / JWT_SECRET / CF_CALLS_APP_ID etc.
# DB_PASS / JWT_SECRET / R2_* 등 입력
# Pull images and start everything
# 이미지 풀 및 시작
docker compose up -d
# Check service status
# 서비스 상태 확인
docker compose ps
# Open in browser
# 브라우저에서 열기
open http://localhost
```
> Pre-built images on Docker Hub:
> Docker Hub의 사전 빌드 이미지:
> - `facilisvelox/paperphone-client:latest`
> - `facilisvelox/paperphone-server:latest`
>
> **Note**: The server automatically initialises the database schema on first startup — no manual SQL import required.
> **참고**: 서버는 첫 시작 시 자동으로 데이터베이스 스키마를 초기화합니다 — SQL 수동 임포트가 필요하지 않습니다.
### Option 2: Manual Local Start
### 방법 2: 로컬 수동 시작
#### 1. Prepare the environment
#### 1. 환경 준비
```bash
# Copy and edit environment variables
# 환경 변수 복사 및 편집
cp server/.env.example server/.env
# Fill in DB_HOST / DB_PASS / REDIS_HOST / R2_* etc.
# DB_HOST / DB_PASS / REDIS_HOST / R2_* 등 입력
# Note: the server auto-runs schema.sql on first startup
# 참고: 서버는 첫 시작 시 자동으로 schema.sql을 실행합니다
```
#### 2. Start the backend
#### 2. 백엔드 시작
```bash
cd server
@@ -123,7 +124,7 @@ npm install
npm run dev # → http://localhost:3000
```
#### 3. Start the frontend
#### 3. 프론트엔드 시작
```bash
npx serve client -p 8080
@@ -132,54 +133,54 @@ npx serve client -p 8080
---
## Video Call Configuration
## 영상 통화 설정
Video and voice calls use WebRTC P2P and work out of the box on the same LAN. For cross-network calls, a TURN server is required for NAT traversal.
영상 및 음성 통화는 WebRTC P2P를 사용하며 동일 LAN에서 바로 사용할 수 있습니다. 다른 네트워크 간 통화에는 NAT 트래버설을 위한 TURN 서버가 필요합니다.
### Using Cloudflare TURN (Recommended)
### Cloudflare TURN 사용 (권장)
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → create an App
2. Copy the **App ID** and **App Secret** (Token Key)
3. Add them to `server/.env`:
1. [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → 앱 생성
2. **App ID** **App Secret** (토큰 키) 복사
3. `server/.env`에 추가:
```env
CF_CALLS_APP_ID=your_app_id_here
CF_CALLS_APP_SECRET=your_app_secret_here
```
4. Restart the backend — TURN credentials are fetched automatically per call session (TTL: 86 400 s)
4. 백엔드 재시작 — TURN 자격 증명은 통화 세션마다 자동 발급됩니다 (TTL: 86,400)
> **Without credentials**: the server falls back to STUN only (Google + Cloudflare public STUN). Calls work on LAN without any extra configuration.
> **미설정 시**: STUN 전용으로 폴백합니다 (Google + Cloudflare 공개 STUN). LAN 통화는 추가 설정 없이 작동합니다.
### Call Types
### 통화 유형
| Type | Transport | Recommended for |
|------|-----------|-----------------|
| 1:1 Video Call | WebRTC P2P + TURN | All scenarios |
| 1:1 Voice Call | WebRTC P2P + TURN | All scenarios |
| Group Video / Voice | WebRTC Mesh (full-mesh) | Up to 6 participants |
| 유형 | 전송 방식 | 권장 사용 |
|------|-----------|-----------|
| 1:1 영상 통화 | WebRTC P2P + TURN | 모든 시나리오 |
| 1:1 음성 통화 | WebRTC P2P + TURN | 모든 시나리오 |
| 그룹 영상/음성 | WebRTC Mesh (풀 메시) | 최대 6명 |
---
## Push Notification Configuration
## 푸시 알림 설정
Offline message notifications are delivered through **two channels** for maximum delivery rate:
오프라인 메시지 알림은 **두 채널**을 통해 배달되어 최대 전달률을 보장합니다:
| Channel | Platforms | Configuration |
|---------|-----------|---------------|
| Web Push (VAPID) | Browsers (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID keys |
| OneSignal | Native Android/iOS apps via Median.co | OneSignal App ID + REST Key |
| 채널 | 플랫폼 | 설정 |
|------|--------|------|
| Web Push (VAPID) | 브라우저 (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID |
| OneSignal | Median.co 경유 네이티브 Android/iOS 앱 | OneSignal App ID + REST Key |
### Configuring Web Push
### Web Push 설정
1. Generate VAPID keys (one-time):
1. VAPID 키 생성 (1회만):
```bash
cd server
npx web-push generate-vapid-keys
```
2. Add to `server/.env`:
2. `server/.env`에 추가:
```env
VAPID_PUBLIC_KEY=your_public_key_here
@@ -187,37 +188,37 @@ VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:admin@your-domain.com
```
3. Restart the server — users can enable notifications from the Settings page
3. 서버 재시작 — 사용자가 설정 페이지에서 알림을 활성화할 수 있습니다
> **iOS users** must first "Add to Home Screen" (PWA), and only iOS 16.4+ is supported.
> **iOS 사용자**는 먼저 "홈 화면에 추가" (PWA)를 해야 하며, iOS 16.4 이상만 지원됩니다.
### Configuring OneSignal (Median.co Native Apps)
### OneSignal 설정 (Median.co 네이티브 앱)
1. Create an app on [OneSignal Dashboard](https://onesignal.com) and configure Firebase
2. Enable OneSignal in Median.co and enter the App ID
3. Add the OneSignal **App ID** and **REST API Key** to `server/.env`:
1. [OneSignal Dashboard](https://onesignal.com)에서 앱 생성 및 Firebase 설정
2. Median.co에서 OneSignal 활성화 후 App ID 입력
3. OneSignal **App ID** **REST API Key** `server/.env`에 추가:
```env
ONESIGNAL_APP_ID=your_onesignal_app_id
ONESIGNAL_REST_KEY=your_onesignal_rest_api_key
```
> **When not configured**: push notifications are silently disabled — all other features work normally.
> **미설정 시**: 푸시 알림이 자동으로 비활성화됩니다 — 다른 기능에는 영향 없습니다.
---
## iOS — Permanent No-Cert Deployment
## iOS — 인증서 없는 영구 배포
1. Deploy to a server with an HTTPS domain (required for WebRTC and Web Crypto APIs)
2. Open `https://your.domain.com` in **Safari**
3. Tap the Share button ⬆️ at the bottom of the screen
4. Select **Add to Home Screen** → **Add**
1. HTTPS 도메인 서버에 배포 (WebRTC Web Crypto API에 HTTPS 필요)
2. **Safari**에서 `https://your.domain.com` 열기
3. 화면 하단의 공유 버튼 ⬆️ 탭
4. **홈 화면에 추가** → **추가** 선택
The app behaves identically to a native app — no Apple enterprise certificate required, no expiry.
네이티브 앱과 동일하게 작동합니다 — Apple 기업 인증서 불필요, 만료 없음.
---
## Production Deployment (Nginx)
## 프로덕션 배포 (Nginx)
```nginx
server {
@@ -227,7 +228,7 @@ server {
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Frontend static files
# 프론트엔드 정적 파일
location / {
root /path/to/paperphone/client;
try_files $uri /index.html;
@@ -239,7 +240,7 @@ server {
proxy_set_header Host $host;
}
# WebSocket (messaging + call signalling)
# WebSocket (메시징 + 통화 시그널링)
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
@@ -252,125 +253,69 @@ server {
---
## Project Structure
## 데이터베이스 스키마
11개 테이블, 서버 첫 시작 시 자동 생성 (`CREATE TABLE IF NOT EXISTS`):
| 테이블 | 용도 |
|--------|------|
| `users` | 사용자 프로필 + ECDH/OPK 공개 키 |
| `prekeys` | X3DH 원타임 프리키 풀 |
| `friends` | 친구 관계 (pending / accepted / blocked) |
| `groups` / `group_members` | 그룹 채팅 + 멤버 (알림 끄기 상태 포함) |
| `messages` | 암호화된 메시지 (오프라인 버퍼, 전달 후 삭제 가능) |
| `moments` | 소셜 게시물 (텍스트 ≤ 1024자) |
| `moment_images` | 게시물 이미지 (게시물당 최대 9개) |
| `moment_likes` | 좋아요 (사용자당 게시물당 고유) |
| `moment_comments` | 댓글 (≤ 512자) |
| `push_subscriptions` | Web Push 구독 (VAPID) |
| `onesignal_players` | OneSignal 기기 등록 (Median.co) |
---
## 보안 모델
```
paperphone/
├── docker-compose.yml
├── server/
│ ├── .env # Environment variables (incl. Cloudflare TURN keys)
│ └── src/
├── app.js # Express application
├── routes/
│ ├── auth.js # Register / Login (incl. X3DH public key upload)
│ ├── users.js # User search / Prekey bundle download
│ │ ├── friends.js # Friend requests / Accept (incl. offline push)
│ │ ├── groups.js # Group management
│ ├── messages.js # Historical messages (paginated ciphertext)
│ │ ├── upload.js # Cloudflare R2 file upload
│ │ ├── files.js # File proxy (when R2_PUBLIC_URL is not set)
│ │ ├── moments.js # Moments feed (posts / likes / comments)
│ │ ├── calls.js # TURN credential issuance
│ │ └── push.js # Push subscription mgmt (Web Push + OneSignal)
│ ├── services/
│ │ ├── push.js # Web Push VAPID service
│ │ └── onesignal.js # OneSignal REST API service
│ └── ws/
│ └── wsServer.js # WebSocket router (messages + call signalling + offline push)
└── client/
├── index.html # SPA entry + PWA meta + Median push bridge
├── manifest.json # PWA manifest
├── sw.js # Service Worker (offline cache + push notifications)
└── src/
├── style.css # Premium design system (dark/light, glassmorphism)
├── app.js # Router + global state + incoming call listener
├── api.js # HTTP client
├── socket.js # WebSocket client (auto-reconnect)
├── i18n.js # Multi-language engine (zh / en / ja / ko / fr / de / ru / es)
├── services/
│ ├── webrtc.js # WebRTC manager — CallManager class
│ └── pushNotification.js # Push subscription mgmt (Web Push + Median bridge)
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # IndexedDB private key store
├── pages/
│ ├── login.js # Login / Register (key generation, language picker)
│ ├── chats.js # Chat list
│ ├── chat.js # Chat window (E2E encryption, call buttons)
│ ├── groups.js # Group list (create group, search)
│ ├── groupInfo.js # Group info (member management, DND, leave/disband)
│ ├── contacts.js # Contacts (friend requests, online status)
│ ├── discover.js # Discover page
│ ├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
│ └── call.js # Call UI (incoming / active / multi-party video)
등록 시:
기기가 IK (아이덴티티 키) + SPK (서명 프리키) + 10× OPK (원타임 프리키) 생성
공개 키는 업로드, 개인 키는 기기에 보관 (4단계 영속화)
메시지 전송 시:
발신자가 수신자의 IK 공개 키를 가져옴
임시 ECDH 키페어 생성 (메시지마다 새로운 페어)
X25519 ECDH → 공유 비밀 → XSalsa20-Poly1305 암호화
임시 공개 키는 메시지 헤더로 전송, 사용 후 파기
서버가 보는 것:
✅ 암호문 블롭 + 라우팅 메타데이터 (발신자/수신자 UUID, 타임스탬프)
❌ 평문 / 개인 키 / 임시 키 / 통화 내용
```
---
## Database Schema
## 환경 변수
11 tables, auto-created on first server startup (`CREATE TABLE IF NOT EXISTS`):
| Table | Purpose |
|-------|---------|
| `users` | User profiles + ECDH/OPK public keys |
| `prekeys` | X3DH one-time prekey pool |
| `friends` | Friendship relationships (pending / accepted / blocked) |
| `groups` / `group_members` | Group chats + membership (incl. mute/DND status) |
| `messages` | Encrypted payloads (offline buffer, deletable after delivery) |
| `moments` | Social posts (text ≤ 1024 chars) |
| `moment_images` | Post images (up to 9 per post) |
| `moment_likes` | Likes (unique per user per post) |
| `moment_comments` | Comments (≤ 512 chars each) |
| `push_subscriptions` | Web Push subscriptions (VAPID) |
| `onesignal_players` | OneSignal device registrations (Median.co) |
| 변수 | 설명 | 기본값 |
|------|------|--------|
| `PORT` | 서버 포트 | `3000` |
| `JWT_SECRET` | JWT 서명 키 (**프로덕션에서 반드시 변경**) | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL 연결 | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis 연결 | — |
| `R2_ACCOUNT_ID` | Cloudflare 계정 ID | — |
| `R2_ACCESS_KEY_ID` | R2 API 토큰 액세스 키 | — |
| `R2_SECRET_ACCESS_KEY` | R2 API 토큰 시크릿 키 | — |
| `R2_BUCKET` | R2 버킷 이름 | — |
| `R2_PUBLIC_URL` | R2 공개 기본 URL (선택) — CDN 직접 링크 활성화 | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID (선택) | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret (선택) | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID 공개 키 (선택) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID 개인 키 (선택) | — |
| `VAPID_SUBJECT` | VAPID 연락처 이메일 (선택) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID (선택, Median.co용) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key (선택) | — |
---
## Security Model
```
On Registration:
Device generates IK (Identity Key) + SPK (Signed PreKey) + 10× OPK (One-Time PreKeys)
Public keys are uploaded; private keys stay on-device (four-tier persistence)
On Each Message:
Sender fetches recipient's IK public key
Generates a fresh ephemeral ECDH keypair (per-message)
X25519 ECDH → shared secret → XSalsa20-Poly1305 encrypt
Ephemeral public key sent in message header; destroyed after use
What the Server Sees:
✅ Ciphertext blob + routing metadata (sender / recipient UUID, timestamps)
❌ Plaintext / private keys / ephemeral keys / call content
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `JWT_SECRET` | JWT signing key (**change in production**) | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL connection | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis connection | — |
| `R2_ACCOUNT_ID` | Cloudflare account ID | — |
| `R2_ACCESS_KEY_ID` | R2 API token access key | — |
| `R2_SECRET_ACCESS_KEY` | R2 API token secret key | — |
| `R2_BUCKET` | R2 bucket name | — |
| `R2_PUBLIC_URL` | R2 public base URL (optional) — enables direct CDN links | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID (optional) | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret (optional) | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID public key (optional) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID private key (optional) | — |
| `VAPID_SUBJECT` | VAPID contact email (optional) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID (optional, for Median.co) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key (optional) | — |
---
## License
## 라이선스
MIT © PaperPhone Contributors

View File

@@ -10,367 +10,173 @@
---
<img width=30% height=30% src="https://raw.githubusercontent.com/619dev/PaperPhone/main/ui.jpg" alt="ui">
## Features
| Feature | Description |
|---------|-------------|
| 🔐 End-to-End Encryption | Stateless ECDH + XSalsa20-Poly1305 — ephemeral keys per message, forward secrecy |
| 🗑️ Zero-Knowledge Server | Server stores only ciphertext; private keys never leave the device |
| 📹 Video & Voice Calls | WebRTC P2P (1:1) + Mesh (group), Cloudflare TURN for NAT traversal |
| 👥 Group Chat | Up to 2000 members, plain-text messages (no encryption), Do Not Disturb mode, member management |
| ⏱️ Auto-Delete Messages | 5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups |
| 🔔 Push Notifications | Web Push (VAPID) + OneSignal dual-channel — reach users even when offline |
| 🌐 Multi-Language | Chinese, English, Japanese, Korean, French, German, Russian, Spanish — auto-detect + manual switch |
| 📱 iOS — No Enterprise Cert | PWA via Safari "Add to Home Screen", works permanently without Apple signing |
| 💬 Rich Messaging | Text, images, voice messages, 64-emoji panel, delivery receipts, typing indicators |
| 🌐 Moments | WeChat-style social feed: text + up to 9 photos, likes (friend avatars), comments, tag-based visibility control |
| 🏷️ Friend Tags | Assign multiple tags to friends (12-color preset palette), filter contacts by tag |
| 🗂️ R2 Object Storage | Cloudflare R2 for image/voice files — optional public CDN URL |
| 🏗 Self-Hostable | Docker Compose one-command deployment; Node.js + Redis multi-node ready |
## Возможности
| Функция | Описание |
|---------|----------|
| 🔐 Сквозное шифрование | Безсостоянийный ECDH + XSalsa20-Poly1305 — эфемерные ключи на каждое сообщение, прямая секретность |
| 🗝️ Сервер с нулевым знанием | Сервер хранит только шифротекст; закрытые ключи никогда не покидают устройство |
| 📹 Видео/аудио звонки | WebRTC P2P (1:1) + Mesh (группа), Cloudflare TURN для NAT-обхода |
| 👥 Групповой чат | До 2000 участников, обычный текст (без шифрования), режим «Не беспокоить», управление участниками |
| ⏱️ Автоудаление | 5 уровней (никогда/1д/3д/1нед/1мес), в ЛС — обе стороны, в группах — только владелец |
| 🔔 Push-уведомления | Web Push (VAPID) + OneSignal — двойной канал |
| 🌐 Многоязычность | ZH/EN/JA/KO/FR/DE/RU/ES — автоопределение + ручной выбор |
| 📱 iOS без сертификата | PWA через Safari «На экран Домой», без корпоративного сертификата Apple |
| 💬 Расширенные сообщения | Текст, изображения, голосовые сообщения, 64 эмодзи, подтверждения прочтения |
| 🌐 Моменты | Социальная лента: текст + до 9 фото, лайки (аватары друзей), комментарии, управление видимостью по тегам |
| 🏷 Теги друзей | Несколько тегов на друга (палитра из 12 цветов), фильтрация контактов по тегам |
| 🗂️ Хранилище R2 | Cloudflare R2 для изображений/аудио — опциональный CDN URL |
| 🏗️ Самостоятельное размещение | Docker Compose в одну команду |
---
## Tech Stack
## Технологический стек
```
Backend (server/)
Бэкенд (server/)
Node.js 20 + Express + ws
MySQL 8.0 users, messages (persisted ciphertext)
Redis — online presence + cross-node routing
Cloudflare R2 — image/voice file storage (S3-compatible API)
JWT + bcrypt authentication
MySQL 8.0 — пользователи, сообщения (шифротекст)
Redis — статус онлайн + маршрутизация между узлами
Cloudflare R2 — хранилище файлов (S3-совместимый API)
JWT + bcrypt аутентификация
Frontend (client/)
Native HTML + Vanilla JS (ESM, no bundler required)
Фронтенд (client/)
Нативный HTML + Vanilla JS (ESM, без сборщика)
libsodium-wrappers (WebAssembly — Curve25519 / XSalsa20-Poly1305)
WebRTC API — video / voice calls
WebRTC API — видео/аудио звонки
PWA: manifest.json + Service Worker
Cryptographic Layer
Stateless ECDH + XSalsa20-Poly1305 — ephemeral keypair per message
Four-tier key persistence: memory → localStorage → sessionStorage → IndexedDB
All private keys stored on-device only — never sent to the server
Криптографический слой
Безсостоянийный ECDH + XSalsa20-Poly1305 — эфемерная пара ключей на сообщение
4-уровневое хранение ключей: память → localStorage → sessionStorage → IndexedDB
Закрытые ключи только на устройстве — никогда не отправляются на сервер
```
---
## Quick Start
## Быстрый старт
### Option 0: Zeabur One-Click Cloud Deploy
### Вариант 0: Zeabur — облачное развёртывание в один клик
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
> [!NOTE]
> One manual step is required after the template deploys, otherwise login/register won't work:
> 1. Go to Zeabur Console → **server service** → Environment Variables → copy the value of `ZEABUR_WEB_URL` (e.g. `http://10.43.x.x:3000`)
> 2. Go to **client service** → Environment Variables → add variable `SERVER_URL` = the value copied above
> 3. Restart the client service
> После развёртывания шаблона необходим один ручной шаг:
> 1. Консоль Zeabur → сервис **server** → Переменные → скопировать `ZEABUR_WEB_URL`
> 2. Сервис **client** → Переменные → добавить `SERVER_URL` = скопированное значение
> 3. Перезапустить сервис client
**Known notes:**
- On first startup, the server automatically creates all database tables (`CREATE TABLE IF NOT EXISTS`) — no manual SQL import needed
- Redis runs without a password inside the cluster (intra-cluster network isolation is sufficient)
- If MySQL access is denied, manually set `DB_PASS` on the server service to the value of `MYSQL_ROOT_PASSWORD` from the MySQL service
- To get the **internal IP** of any service container, open that service's Terminal in the Zeabur console and run:
```bash
hostname -i
```
---
### Option 1: Docker Compose (Recommended — no local build needed)
### Вариант 1: Docker Compose (рекомендуется)
```bash
# Clone the repository
git clone <repo-url> && cd paperphone
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in: DB_PASS / JWT_SECRET / CF_CALLS_APP_ID etc.
# Pull images and start everything
# Заполнить: DB_PASS / JWT_SECRET / R2_* и т.д.
docker compose up -d
# Check service status
docker compose ps
# Open in browser
open http://localhost
```
> Pre-built images on Docker Hub:
> - `facilisvelox/paperphone-client:latest`
> - `facilisvelox/paperphone-server:latest`
>
> **Note**: The server automatically initialises the database schema on first startup — no manual SQL import required.
> Образы Docker Hub: `facilisvelox/paperphone-client:latest` и `facilisvelox/paperphone-server:latest`
### Option 2: Manual Local Start
#### 1. Prepare the environment
### Вариант 2: Локальный ручной запуск
```bash
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in DB_HOST / DB_PASS / REDIS_HOST / R2_* etc.
# Бэкенд
cd server && npm install && npm run dev # → http://localhost:3000
# Note: the server auto-runs schema.sql on first startup
```
#### 2. Start the backend
```bash
cd server
npm install
npm run dev # → http://localhost:3000
```
#### 3. Start the frontend
```bash
npx serve client -p 8080
# → http://localhost:8080
# Фронтенд
npx serve client -p 8080 # → http://localhost:8080
```
---
## Video Call Configuration
Video and voice calls use WebRTC P2P and work out of the box on the same LAN. For cross-network calls, a TURN server is required for NAT traversal.
### Using Cloudflare TURN (Recommended)
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **Calls** → create an App
2. Copy the **App ID** and **App Secret** (Token Key)
3. Add them to `server/.env`:
## Настройка видеозвонков — Cloudflare TURN
```env
CF_CALLS_APP_ID=your_app_id_here
CF_CALLS_APP_SECRET=your_app_secret_here
```
4. Restart the backend — TURN credentials are fetched automatically per call session (TTL: 86 400 s)
| Тип | Транспорт | Рекомендуется для |
|-----|-----------|-------------------|
| 1:1 Видео | WebRTC P2P + TURN | Все сценарии |
| 1:1 Голос | WebRTC P2P + TURN | Все сценарии |
| Групповой | WebRTC Mesh | До 6 участников |
> **Without credentials**: the server falls back to STUN only (Google + Cloudflare public STUN). Calls work on LAN without any extra configuration.
### Call Types
| Type | Transport | Recommended for |
|------|-----------|-----------------|
| 1:1 Video Call | WebRTC P2P + TURN | All scenarios |
| 1:1 Voice Call | WebRTC P2P + TURN | All scenarios |
| Group Video / Voice | WebRTC Mesh (full-mesh) | Up to 6 participants |
> **Без настройки**: только STUN. Звонки в ЛВС работают без дополнительных настроек.
---
## Push Notification Configuration
## Push-уведомления
Offline message notifications are delivered through **two channels** for maximum delivery rate:
| Channel | Platforms | Configuration |
|---------|-----------|---------------|
| Web Push (VAPID) | Browsers (Chrome/Edge/Firefox) + iOS PWA (Safari 16.4+) | VAPID keys |
| OneSignal | Native Android/iOS apps via Median.co | OneSignal App ID + REST Key |
### Configuring Web Push
1. Generate VAPID keys (one-time):
| Канал | Платформы | Настройка |
|-------|-----------|-----------|
| Web Push | Браузеры + iOS PWA (Safari 16.4+) | Ключи VAPID |
| OneSignal | Нативные приложения Median.co | App ID + REST Key |
```bash
cd server
npx web-push generate-vapid-keys
```
2. Add to `server/.env`:
---
```env
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:admin@your-domain.com
```
## iOS — Постоянная установка без сертификата
3. Restart the server — users can enable notifications from the Settings page
> **iOS users** must first "Add to Home Screen" (PWA), and only iOS 16.4+ is supported.
### Configuring OneSignal (Median.co Native Apps)
1. Create an app on [OneSignal Dashboard](https://onesignal.com) and configure Firebase
2. Enable OneSignal in Median.co and enter the App ID
3. Add the OneSignal **App ID** and **REST API Key** to `server/.env`:
```env
ONESIGNAL_APP_ID=your_onesignal_app_id
ONESIGNAL_REST_KEY=your_onesignal_rest_api_key
```
> **When not configured**: push notifications are silently disabled — all other features work normally.
1. Развернуть на HTTPS-сервере → 2. Открыть в Safari → 3. Поделиться ⬆️ → 4. «На экран Домой»
---
## iOS — Permanent No-Cert Deployment
## Модель безопасности
1. Deploy to a server with an HTTPS domain (required for WebRTC and Web Crypto APIs)
2. Open `https://your.domain.com` in **Safari**
3. Tap the Share button ⬆️ at the bottom of the screen
4. Select **Add to Home Screen** → **Add**
The app behaves identically to a native app — no Apple enterprise certificate required, no expiry.
---
## Production Deployment (Nginx)
```nginx
server {
listen 443 ssl http2;
server_name your.domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Frontend static files
location / {
root /path/to/paperphone/client;
try_files $uri /index.html;
}
# REST API
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
}
# WebSocket (messaging + call signalling)
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
```
Регистрация: IK + SPK + 10×OPK генерируются локально, публичные ключи загружаются
Сообщение: Эфемерный ECDH → X25519 → XSalsa20-Poly1305
Сервер видит: ✅ шифротекст + метаданные маршрутизации ❌ открытый текст / закрытые ключи
```
---
## Project Structure
## Схема базы данных
```
paperphone/
├── docker-compose.yml
├── server/
│ ├── .env # Environment variables (incl. Cloudflare TURN keys)
│ └── src/
│ ├── app.js # Express application
│ ├── routes/
│ │ ├── auth.js # Register / Login (incl. X3DH public key upload)
│ │ ├── users.js # User search / Prekey bundle download
│ │ ├── friends.js # Friend requests / Accept (incl. offline push)
│ │ ├── groups.js # Group management
│ │ ├── messages.js # Historical messages (paginated ciphertext)
│ │ ├── upload.js # Cloudflare R2 file upload
│ │ ├── files.js # File proxy (when R2_PUBLIC_URL is not set)
│ │ ├── moments.js # Moments feed (posts / likes / comments)
│ │ ├── calls.js # TURN credential issuance
│ │ └── push.js # Push subscription mgmt (Web Push + OneSignal)
│ ├── services/
│ │ ├── push.js # Web Push VAPID service
│ │ └── onesignal.js # OneSignal REST API service
│ └── ws/
│ └── wsServer.js # WebSocket router (messages + call signalling + offline push)
└── client/
├── index.html # SPA entry + PWA meta + Median push bridge
├── manifest.json # PWA manifest
├── sw.js # Service Worker (offline cache + push notifications)
└── src/
├── style.css # Premium design system (dark/light, glassmorphism)
├── app.js # Router + global state + incoming call listener
├── api.js # HTTP client
├── socket.js # WebSocket client (auto-reconnect)
├── i18n.js # Multi-language engine (zh / en / ja / ko / fr / de / ru / es)
├── services/
│ ├── webrtc.js # WebRTC manager — CallManager class
│ └── pushNotification.js # Push subscription mgmt (Web Push + Median bridge)
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM-768
│ └── keystore.js # IndexedDB private key store
├── pages/
│ ├── login.js # Login / Register (key generation, language picker)
│ ├── chats.js # Chat list
│ ├── chat.js # Chat window (E2E encryption, call buttons)
│ ├── groups.js # Group list (create group, search)
│ ├── groupInfo.js # Group info (member management, DND, leave/disband)
│ ├── contacts.js # Contacts (friend requests, online status)
│ ├── discover.js # Discover page
│ ├── profile.js # Me / Settings (language, fingerprint, notifications, PWA)
│ └── call.js # Call UI (incoming / active / multi-party video)
```
11 таблиц, создаются автоматически при первом запуске:
| Таблица | Назначение |
|---------|------------|
| `users` | Профили + публичные ключи ECDH/OPK |
| `prekeys` | Одноразовые предключи X3DH |
| `friends` | Связи дружбы |
| `groups` / `group_members` | Группы + участники |
| `messages` | Зашифрованные сообщения |
| `moments` / `moment_images` | Посты + изображения |
| `moment_likes` / `moment_comments` | Лайки + комментарии |
| `push_subscriptions` | Web Push (VAPID) |
| `onesignal_players` | OneSignal устройства |
---
## Database Schema
## Переменные окружения
11 tables, auto-created on first server startup (`CREATE TABLE IF NOT EXISTS`):
| Table | Purpose |
|-------|---------|
| `users` | User profiles + ECDH/OPK public keys |
| `prekeys` | X3DH one-time prekey pool |
| `friends` | Friendship relationships (pending / accepted / blocked) |
| `groups` / `group_members` | Group chats + membership (incl. mute/DND status) |
| `messages` | Encrypted payloads (offline buffer, deletable after delivery) |
| `moments` | Social posts (text ≤ 1024 chars) |
| `moment_images` | Post images (up to 9 per post) |
| `moment_likes` | Likes (unique per user per post) |
| `moment_comments` | Comments (≤ 512 chars each) |
| `push_subscriptions` | Web Push subscriptions (VAPID) |
| `onesignal_players` | OneSignal device registrations (Median.co) |
| Переменная | Описание | По умолчанию |
|------------|----------|-------------|
| `PORT` | Порт сервера | `3000` |
| `JWT_SECRET` | Ключ подписи JWT (**изменить в продакшене**) | dev_secret |
| `DB_HOST`/`DB_PASS`/`DB_NAME` | Подключение MySQL | — |
| `REDIS_HOST`/`REDIS_PASS` | Подключение Redis | — |
| `R2_ACCOUNT_ID` | ID аккаунта Cloudflare | — |
| `R2_ACCESS_KEY_ID` | Ключ доступа R2 API | — |
| `R2_SECRET_ACCESS_KEY` | Секретный ключ R2 API | — |
| `R2_BUCKET` | Имя бакета R2 | — |
| `R2_PUBLIC_URL` | Публичный URL R2 (опц.) | — |
| `CF_CALLS_APP_ID` | Calls App ID (опц.) | — |
| `CF_CALLS_APP_SECRET` | Calls Secret (опц.) | — |
| `VAPID_PUBLIC_KEY` | Публичный ключ VAPID (опц.) | — |
| `VAPID_PRIVATE_KEY` | Закрытый ключ VAPID (опц.) | — |
| `ONESIGNAL_APP_ID` | OneSignal App ID (опц.) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST Key (опц.) | — |
---
## Security Model
```
On Registration:
Device generates IK (Identity Key) + SPK (Signed PreKey) + 10× OPK (One-Time PreKeys)
Public keys are uploaded; private keys stay on-device (four-tier persistence)
On Each Message:
Sender fetches recipient's IK public key
Generates a fresh ephemeral ECDH keypair (per-message)
X25519 ECDH → shared secret → XSalsa20-Poly1305 encrypt
Ephemeral public key sent in message header; destroyed after use
What the Server Sees:
✅ Ciphertext blob + routing metadata (sender / recipient UUID, timestamps)
❌ Plaintext / private keys / ephemeral keys / call content
```
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `JWT_SECRET` | JWT signing key (**change in production**) | dev_secret |
| `DB_HOST` / `DB_PASS` / `DB_NAME` | MySQL connection | — |
| `REDIS_HOST` / `REDIS_PASS` | Redis connection | — |
| `R2_ACCOUNT_ID` | Cloudflare account ID | — |
| `R2_ACCESS_KEY_ID` | R2 API token access key | — |
| `R2_SECRET_ACCESS_KEY` | R2 API token secret key | — |
| `R2_BUCKET` | R2 bucket name | — |
| `R2_PUBLIC_URL` | R2 public base URL (optional) — enables direct CDN links | — |
| `CF_CALLS_APP_ID` | Cloudflare Calls App ID (optional) | — |
| `CF_CALLS_APP_SECRET` | Cloudflare Calls App Secret (optional) | — |
| `VAPID_PUBLIC_KEY` | Web Push VAPID public key (optional) | — |
| `VAPID_PRIVATE_KEY` | Web Push VAPID private key (optional) | — |
| `VAPID_SUBJECT` | VAPID contact email (optional) | `mailto:admin@paperphone.app` |
| `ONESIGNAL_APP_ID` | OneSignal App ID (optional, for Median.co) | — |
| `ONESIGNAL_REST_KEY` | OneSignal REST API Key (optional) | — |
---
## License
## Лицензия
MIT © PaperPhone Contributors

View File

@@ -1,4 +1,13 @@
# Nginx config for PaperPhone — serves client and proxies API to server
# ─────────────────────────────────────────────────────────────────
# Nginx config for PaperPhone — serves PWA + reverse-proxy to API
#
# This file is an envsubst template. At container start the
# Dockerfile CMD substitutes ${SERVER_URL} and writes the final
# config to /etc/nginx/conf.d/paperphone.conf.
#
# Docker Compose: SERVER_URL = http://server:3000
# Zeabur: SERVER_URL = ${ZEABUR_WEB_URL} (e.g. http://10.43.x.x:3000)
# ─────────────────────────────────────────────────────────────────
server {
listen 80;
@@ -7,39 +16,42 @@ server {
root /usr/share/nginx/html;
index index.html;
# Gzip compression
# ── Compression ──────────────────────────────────────────────
gzip on;
gzip_types text/plain text/css application/javascript application/json application/wasm;
gzip_min_length 1024;
gzip_vary on;
# Security headers
# ── Security headers ─────────────────────────────────────────
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
# Cache PWA assets aggressively (content-hashed in prod)
# ── Static asset caching ─────────────────────────────────────
location ~* \.(css|js|wasm|png|jpg|webp|ico|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
# Service worker — no-cache so updates propagate immediately
# Service worker — always revalidate so updates propagate
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
# WebSocket upgrade — proxy to backend
# ── WebSocket — proxy to backend ─────────────────────────────
location /ws {
proxy_pass ${SERVER_URL};
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_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# REST API — proxy to backend
# ── REST API — proxy to backend ──────────────────────────────
location /api/ {
proxy_pass ${SERVER_URL};
proxy_http_version 1.1;
@@ -48,14 +60,18 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 51m;
# Long-poll & upload timeout
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Health check from backend
# ── Health check (forwarded to backend) ──────────────────────
location /health {
proxy_pass ${SERVER_URL};
}
# SPA fallback — all other routes serve index.html
# ── SPA fallback ─────────────────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -1,5 +1,3 @@
version: "3.9"
# ─────────────────────────────────────────────────────────────────
# PaperPhone — Full-Stack Docker Compose
#
@@ -8,14 +6,17 @@ version: "3.9"
# server — Node.js API + WebSocket
# mysql — Message store / user DB
# redis — Online presence & session cache
# minio — S3-compatible object storage for media uploads
#
# File storage:
# Cloudflare R2 (configured via R2_* env vars in server/.env)
# No MinIO / S3 container needed.
#
# Images:
# docker pull facilisvelox/paperphone-client:latest
# docker pull facilisvelox/paperphone-server:latest
#
# Quick start:
# cp server/.env.example server/.env # edit secrets & CF_CALLS_APP_ID
# cp server/.env.example server/.env # edit secrets & R2 keys
# docker compose up -d
# ─────────────────────────────────────────────────────────────────
@@ -26,6 +27,8 @@ services:
image: facilisvelox/paperphone-client:latest
ports:
- "80:80"
environment:
SERVER_URL: http://server:3000
depends_on:
server:
condition: service_healthy
@@ -40,17 +43,16 @@ services:
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_USER: ${DB_USER:-paperphone}
DB_PASS: ${DB_PASS:-changeme}
DB_NAME: paperphone
REDIS_HOST: redis
REDIS_PORT: 6379
MINIO_ENDPOINT: minio
MINIO_PORT: 9000
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
@@ -97,30 +99,9 @@ services:
networks:
- paperphone
# ── MinIO (S3-compatible) ─────────────────────────────────────
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
volumes:
- minio_data:/data
ports:
- "9001:9001" # MinIO console (remove in prod if not needed)
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- paperphone
volumes:
mysql_data:
redis_data:
minio_data:
networks:
paperphone:

View File

@@ -10,12 +10,13 @@ DB_NAME=paperphone
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASS=
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=paperphone
# Cloudflare R2 (S3-compatible object storage)
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=paperphone
R2_PUBLIC_URL=
# Cloudflare Calls (TURN service)
# Get from: Cloudflare Dashboard → Calls → Your App → API Keys

View File

@@ -1,5 +1,5 @@
# ─────────────────────────────────────────────────────────────────
# PaperPhone — Production Environment Variables
# PaperPhone — Production Environment Variables (Docker Compose)
# Copy this file to server/.env and fill in real values
# ─────────────────────────────────────────────────────────────────
@@ -13,7 +13,7 @@ JWT_SECRET=CHANGE_ME_USE_OPENSSL_RAND_HEX_32
JWT_EXPIRES_IN=7d
# ── MySQL ─────────────────────────────────────────────────────────
# In Docker, DB_HOST is the service name: mysql
# In Docker Compose, DB_HOST is the service name: mysql
DB_HOST=mysql
DB_PORT=3306
DB_USER=paperphone
@@ -21,17 +21,30 @@ DB_PASS=CHANGE_ME_DB_PASSWORD
DB_NAME=paperphone
# ── Redis ─────────────────────────────────────────────────────────
# In Docker, REDIS_HOST is the service name: redis
# In Docker Compose, REDIS_HOST is the service name: redis
REDIS_HOST=redis
REDIS_PORT=6379
# Optional password (matches redis command --requirepass)
REDIS_PASS=
# ── MinIO ─────────────────────────────────────────────────────────
# In Docker, MINIO_ENDPOINT is the service name: minio
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=CHANGE_ME_MINIO_ACCESS
MINIO_SECRET_KEY=CHANGE_ME_MINIO_SECRET
MINIO_BUCKET=paperphone
# ── Cloudflare R2 (S3-compatible object storage) ─────────────────
# Get from: Cloudflare Dashboard → R2 → Manage API Tokens
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=paperphone
R2_PUBLIC_URL=
# ── Cloudflare TURN (optional — for cross-network WebRTC calls) ──
# Get from: Cloudflare Dashboard → Workers & Pages → Calls
CF_CALLS_APP_ID=
CF_CALLS_APP_SECRET=
# ── Web Push / VAPID (optional) ──────────────────────────────────
# Generate with: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@paperphone.app
# ── OneSignal (optional — Median.co native push) ─────────────────
ONESIGNAL_APP_ID=
ONESIGNAL_REST_KEY=

View File

@@ -18,21 +18,26 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASS=
# MinIO
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=paperphone
# Cloudflare R2 (S3-compatible object storage)
# Get from Cloudflare Dashboard → R2 → Manage API Tokens
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=paperphone
R2_PUBLIC_URL=
# Web Push (VAPID)
# Cloudflare TURN (optional — for cross-network WebRTC calls)
# Get from Cloudflare Dashboard → Workers & Pages → Calls
CF_CALLS_APP_ID=
CF_CALLS_APP_SECRET=
# Web Push (VAPID) — optional
# Generate with: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@paperphone.app
# OneSignal (Median.co native push)
# OneSignal (Median.co native push) — optional
# Get from OneSignal Dashboard → Settings → Keys & IDs
ONESIGNAL_APP_ID=
ONESIGNAL_REST_KEY=

View File

@@ -25,10 +25,11 @@ spec:
## Features
- 📹 **Video & Voice Calls** — WebRTC P2P (1:1) + Mesh (group up to 6)
- 🌐 **Multi-language** — Chinese, English, Japanese, Korean, French
- 🌐 **Multi-language** — Chinese, English, Japanese, Korean, French, German, Russian, Spanish
- 📱 **iOS PWA** — "Add to Home Screen" via Safari, no enterprise cert needed
- 💬 Rich messaging: text, images, voice, emoji, delivery receipts, typing indicators
- 🗂️ **Cloudflare R2** file storage (images & voice messages)
- 🏷️ **Friend Tags** — tag-based contact filtering & Moments visibility control
## After Deployment
1. Open the domain assigned to the **client** service.
@@ -386,10 +387,11 @@ localization:
## 功能亮点
- 📹 **视频/语音通话** — WebRTC P2P1:1+ Mesh 多人≤6 人)
- 🌐 **多语言** — 中文、英文、日语、韩语、法语
- 🌐 **多语言** — 中文、英文、日语、韩语、法语、德语、俄语、西班牙语
- 📱 **iOS 永久免签** — Safari「添加到主屏幕」无需企业证书
- 💬 富文本消息、图片、语音、Emoji、送达回执、打字状态
- 🗂️ **Cloudflare R2** 对象存储(图片与语音消息)
- 🏷️ **好友标签** — 标签分类筛选通讯录 + 朋友圈可见性控制
## 部署后操作
1. 打开分配给 **client** 服务的域名。