PaperPhone IM
🌐 Other Languages: 中文 · 日本語 · 한국어 · Français · Deutsch · Русский · Español
A WeChat-style end-to-end encrypted instant messaging app with stateless ECDH + XSalsa20-Poly1305 per-message encryption, real-time video calls, Cloudflare R2 file storage, multi-language support and iOS PWA deployment.
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, video, document files (PDF/DOCX/XLSX etc. with type icons), voice messages, 200+ emoji panel (8 categories), Telegram sticker packs, delivery receipts, typing indicators |
| 🌐 Moments | WeChat-style social feed: text + up to 9 photos or 1 video (≤ 10 min), likes (friend avatars), comments, tag-based visibility control |
| 👤 User Profile | Contact profile page (avatar / nickname / Moments feed), with "Hide their Moments" and "Hide my Moments from them" bidirectional privacy controls |
| 📰 Timeline | Xiaohongshu-style public feed — dual-column masonry layout, images/video + text (up to 50 media, 2000 chars), anonymous posting, likes & comments |
| 🏷️ 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 |
| 🔑 Two-Factor Auth (2FA) | Google Authenticator–compatible TOTP, 8 one-time recovery codes, enforced at login |
| 📷 QR Code Scan & Share | Scan QR codes to add friends or join groups; group QR codes support configurable expiry (1 week / 1 month / 3 months) |
| 🏗️ Self-Hostable | Docker Compose one-command deployment; Node.js + Redis multi-node ready |
Tech 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
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
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
Quick Start
Option 0: Zeabur One-Click Cloud Deploy
Note
One manual step is required after the template deploys, otherwise login/register won't work:
- Go to Zeabur Console → server service → Environment Variables → copy the value of
ZEABUR_WEB_URL(e.g.http://10.43.x.x:3000)- Go to client service → Environment Variables → add variable
SERVER_URL= the value copied above- Restart the client service
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_PASSon the server service to the value ofMYSQL_ROOT_PASSWORDfrom the MySQL service - To get the internal IP of any service container, open that service's Terminal in the Zeabur console and run:
hostname -i
Option 1: Docker Compose (Recommended — no local build needed)
# 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
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:latestfacilisvelox/paperphone-server:latestNote: The server automatically initialises the database schema on first startup — no manual SQL import required.
Option 2: Manual Local Start
1. Prepare the environment
# Copy and edit environment variables
cp server/.env.example server/.env
# Fill in DB_HOST / DB_PASS / REDIS_HOST / R2_* etc.
# Note: the server auto-runs schema.sql on first startup
2. Start the backend
cd server
npm install
npm run dev # → http://localhost:3000
3. Start the 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)
- Log in to Cloudflare Dashboard → Workers & Pages → Calls → create an App
- Copy the App ID and App Secret (Token Key)
- Add them to
server/.env:
CF_CALLS_APP_ID=your_app_id_here
CF_CALLS_APP_SECRET=your_app_secret_here
- 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 |
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 |
Configuring Web Push
- Generate VAPID keys (one-time):
cd server
npx web-push generate-vapid-keys
- Add to
server/.env:
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:admin@your-domain.com
- 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)
- Create an app on OneSignal Dashboard and configure Firebase
- Enable OneSignal in Median.co and enter the App ID
- Add the OneSignal App ID and REST API Key to
server/.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
- Deploy to a server with an HTTPS domain (required for WebRTC and Web Crypto APIs)
- Open
https://your.domain.comin Safari - Tap the Share button ⬆️ at the bottom of the screen
- Select Add to Home Screen → Add
The app behaves identically to a native app — no Apple enterprise certificate required, no expiry.
Production Deployment (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;
}
}
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 / user-level privacy)
│ │ ├── timeline.js # Timeline (public posts / likes / comments / anonymous)
│ │ ├── calls.js # TURN credential issuance
│ │ ├── push.js # Push subscription mgmt (Web Push + OneSignal)
│ │ ├── stickers.js # Telegram sticker pack proxy (cached)
│ │ └── totp.js # TOTP two-factor auth (setup / verify / recovery)
│ ├── 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)
│ ├── qrcode.js # QR code generator (inline encoder, zero dependencies)
│ └── scanner.js # Camera QR scanner + album scan (jsQR)
├── 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)
│ ├── userProfile.js # Contact profile (Moments feed + privacy toggles)
│ └── call.js # Call UI (incoming / active / multi-party video)
└── components/
├── tagManager.js # Tag management component
├── momentCard.js # Reusable Moment card component
└── qrUI.js # QR code display / scan result handler component
Database Schema
19 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_videos |
Post videos (thumbnail + duration, 1 per post, ≤ 10 min) |
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) |
user_totp |
TOTP two-factor auth secrets & recovery codes |
moment_privacy |
User-level Moments privacy settings (hide their / hide mine) |
timeline_posts |
Timeline posts (text ≤ 2000 chars, optional anonymous) |
timeline_media |
Timeline media (images/videos, up to 50 per post) |
timeline_likes |
Timeline likes |
timeline_comments |
Timeline comments (optional anonymous) |
group_invites |
Group invite links (with expiry, for QR code joining) |
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) | — |
TELEGRAM_BOT_TOKEN |
Telegram Bot Token (optional, sticker pack proxy) | — |
STICKER_PACKS |
Custom sticker pack list (optional, comma-separated name:label, unlimited) |
8 built-in defaults |
License
MIT © PaperPhone Contributors













