README_EN.md

August 11, 2026 Β· View on GitHub

🌐 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.

Rust React TypeScript MySQL Redis WebRTC License: AGPL v3

Deploy on Zeabur

Version

Google Play App Store Windows Mac


πŸ“Έ Screenshots (click to expand) ui1 ui2 ui3 ui4 ui5 ui6 ui7 ui8 ui9 ui10 ui11 ui12 ui13 ui14 ui15 ui16 ui17 ui18

Features

FeatureDescription
πŸ” End-to-End EncryptionStateless ECDH + XSalsa20-Poly1305 β€” ephemeral keys per message, forward secrecy, Signal-style safety number verification
πŸ—οΈ Zero-Knowledge ServerServer stores only ciphertext; private keys never leave the device
πŸ“Ή Video & Voice CallsLiveKit SFU for 1:1 calls and meetings (up to 100 participants), host mute-all and lecture mode
πŸŽ™οΈ Voice ChangerReal-time voice effects for voice messages, 1:1 calls, and group calls β€” 3 modes (0.8x deep / 1.0x normal / 1.2x high-pitched), powered by Web Audio API
πŸ“± Session Persistence30-minute access tokens with silently renewed 90-day device refresh tokens; reconnects after network/IP/VPN/proxy changes and asks for credentials only when the durable session expires or is revoked
πŸ“¨ Reliable Message SyncBidirectional heartbeat, dead-connection detection, persistent outbox, idempotent client message IDs, and server-sequence catch-up recover messages even when push arrives but realtime delivery is lost
πŸ“΄ Offline AccessAccount-isolated caching for contacts, groups, up to 2,000 messages per conversation, Moments, Timeline, and media; offline sends remain queued and retry automatically
πŸ”Ž Unicode Friend SearchIME composition protection, NFC normalization, and UTF-8 query encoding provide reliable Chinese username and nickname search
πŸ‘₯ Group ChatUp to 2000 members, switchable "Encrypted" / "Unencrypted" modes (owner-only toggle, switching clears chat history). Encrypted mode uses Signal-style Sender Key protocol (XSalsa20-Poly1305 symmetric encryption + ECDH key distribution) β€” only group members can decrypt messages; bots are disabled in encrypted mode. Do Not Disturb mode, member management
πŸ‘« Friend SystemFriend requests require approval with up to 512-char message; custom nicknames; multi-tag grouping
⏱️ Auto-Delete Messages5 tiers (never / 1 day / 3 days / 1 week / 1 month), settable by either party in DMs, owner-only in groups
πŸ”” Push NotificationsWeb Push (VAPID) + FCM + OneSignal + ntfy + APNS five-channel β€” reach users even when offline (iOS native + Chinese Android without Google Services supported)
🌐 Multi-LanguageChinese, English, Japanese, Korean, French, German, Russian, Spanish β€” auto-detect + manual switch
πŸ“± iOS β€” No Enterprise CertPWA via Safari "Add to Home Screen", works permanently without Apple signing
πŸ“± Android Native AppAvailable on Google Play, with FCM push notification support
πŸ“± iOS Native AppAvailable on the App Store, with APNS push notification support
πŸ–₯️ Windows Desktop ClientNative Windows desktop app, download here
🍎 Mac Desktop ClientNative Mac desktop app, download here
πŸ’¬ Rich MessagingText, images, video, document files, voice messages, 200+ emoji, Telegram sticker packs, delivery receipts, typing indicators
πŸ“€ File UploadUp to 500MB per file, Cloudflare R2 or local storage, with progress animation
🌐 MomentsWeChat-style social feed: text + up to 9 photos or 1 video (≀ 10 min), likes, comments, tag-based visibility
πŸ‘€ User ProfileContact profile page with bidirectional Moments privacy controls
πŸ“° TimelineXiaohongshu-style public feed β€” dual-column masonry layout, anonymous posting, likes & comments
🏷️ Friend TagsAssign multiple tags to friends (12-color palette), filter contacts by tag
πŸ—‚οΈ R2 Object StorageCloudflare R2 for image/voice files β€” optional public CDN URL
πŸ”‘ Two-Factor Auth (2FA)Google Authenticator–compatible TOTP, 8 recovery codes, enforced at login
πŸ“· QR Code Scan & ShareScan QR codes to add friends or join groups with configurable expiry
πŸ—οΈ Self-HostableDocker Compose, Zeabur one-click, or frontend on Vercel
🌐 Proxy SettingsSOCKS5 / HTTP / HTTPS proxy support β€” configurable on both login and settings pages with server address, port, username and password for restricted network environments
πŸ›‘οΈ Content ModerationUser reporting (6 reason categories) + user blocking (instantly hides posts/messages) + Terms of Use (EULA)
πŸ”§ Admin PanelEmbedded web admin dashboard (/admin, path customizable), password-protected, review reports, delete offending content, ban users β€” supports 8 languages

What's New in v2.3.9

  • Fixed legacy one-way friendship records causing an β€œAlready friends” message while the contact remained invisible and unavailable for chat; adding the user again now repairs both directions and refreshes the contact list immediately.

What's New in v2.3.8

  • Fixed the unresponsive back button after the QR scanner starts the camera; closing now stops and releases the camera immediately.
  • Fixed duplicate friend requests to existing friends corrupting the friendship; search results now clearly show β€œAlready friends.”
  • Outgoing private messages populate the optimistic message object with ciphertext immediately after end-to-end encryption, preventing plaintext from being briefly persisted while awaiting the server acknowledgement.
  • Voice messages stop automatically at 120 seconds; voice-changed output follows the same limit.
  • Voice recording and active calls keep the screen awake, while page cleanup reliably releases recording devices and timers.
  • Android additionally protects keys and chat caches with Android Keystore and AES-256-GCM; the Web client retains its browser storage model.

Session Recovery and Message Reliability

PaperPhonePlus treats local account state, realtime connection state, and message synchronization as separate concerns. An open WebSocket is not considered usable until the server returns auth_ok. Bidirectional ping/pong heartbeats detect half-open connections caused by VPN/IP changes, Wi-Fi/cellular handoff, or application suspension.

  • Access tokens last 30 minutes. Device refresh tokens last 90 days and extend while actively used, allowing silent renewal without asking for a password.
  • Devices already signed in on an older release are upgraded automatically while their existing token remains valid. If that legacy token has already expired, one final manual sign-in is required.
  • Every outbound message has a stable client_msg_id. Messages without a server ACK remain in the persistent local outbox and retry with the same ID; a server uniqueness constraint prevents duplicate inserts.
  • Every stored message has a monotonically increasing server_seq. The client performs cursor-based catch-up after authentication, reconnection, and foreground resume, so a push notification cannot permanently get ahead of local message history.
  • Explicit logout and device revocation invalidate the durable server session. Ordinary transport failures and IP changes preserve it.

Important

Deploy the server before releasing the updated client. On startup, the server automatically applies and verifies the reliability schema migration. It refuses to start when critical columns are missing, preventing a partially upgraded deployment from silently losing sends. Back up MySQL before production upgrades.


Tech Stack

Backend (server/)
  Rust (Axum 0.8) β€” High-performance async web framework
  sqlx + MySQL 8.0 β€” User/message persistence
  deadpool-redis + Redis 7 β€” Online presence + cross-node routing
  aws-sdk-s3 β€” Cloudflare R2 file storage (S3-compatible API)
  argon2 + jsonwebtoken authentication

Frontend (client/)
  React 19 + TypeScript + Vite 6
  Zustand state management
  libsodium-wrappers-sumo (WebAssembly β€” Curve25519 / XSalsa20-Poly1305)
  WebRTC API β€” video / voice calls
  Web Audio API β€” real-time voice changer (ScriptProcessorNode audio chain)
  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

πŸ“– Detailed Deployment Guide β€” Complete step-by-step instructions for Zeabur + Vercel hybrid deployment, Docker Compose + Nginx local deployment, and client server address configuration.

Option 0: Zeabur One-Click Cloud Deploy

Deploy on Zeabur

Zeabur call network limitation: The template deploys LiveKit using WebSocket/API 7880 and ICE/TCP 7881. Zeabur currently does not expose UDP service ports, so direct calls and meetings use TCP fallback and may have higher latency or lower quality on weak networks. UDP 7882 is already reserved in the LiveKit configuration. For production-quality calls, use LiveKit Cloud or host LiveKit on a VM with UDP support.

Server-side Nginx configuration

Use the production two-domain configuration at deploy/nginx/paperphone-plus.conf. Replace api.example.com and meeting.example.com, obtain TLS certificates for both domains, copy the file to /etc/nginx/sites-available/paperphone-plus, enable it, and run sudo nginx -t && sudo systemctl reload nginx. Set LIVEKIT_URL=wss://meeting.example.com on the backend. Nginx proxies only API and WebSocket traffic; expose LiveKit TCP 7881 and UDP 7882 directly through the host and cloud firewall.

Tip

Advanced: Zeabur + Vercel Hybrid Deployment After deploying on Zeabur, you can manually delete the client service and deploy the frontend on Vercel instead (see Option 2 below). This way server/MySQL/Redis are hosted on Zeabur while the frontend is accelerated by Vercel's global CDN. The frontend requires no environment variables on Vercel β€” users simply enter the backend server address on the login page.

git clone <repo-url> && cd paperphone-plus
cp server/.env.example server/.env
# Edit: DB_PASS / JWT_SECRET / LIVEKIT_URL etc.
docker compose up -d
open http://localhost

Option 2: Frontend on Vercel

# 1. Fork this repo
# 2. Import in Vercel: Root Directory = client/, Build = npm run build, Output = dist/
#    No environment variables needed
# 3. Deploy backend via Docker or Zeabur
# 4. Open the Vercel-deployed frontend, enter the backend server address on the login page
#    e.g. https://your-server.zeabur.app

Option 3: Local Development

# Backend (Rust)
cd server && cp .env.example .env && cargo run --release

# Frontend (React)
cd client && npm install && npm run dev

Voice Changer

Voice messages, 1:1 calls, and group calls all support real-time voice changing with 3 selectable modes:

ModeSpeedEffect
🐒 Slow0.8xDeeper, lower-pitched voice β€” ideal for anonymity
πŸ”Š Normal1.0xOriginal voice, no processing
πŸ‡ Fast1.2xHigher-pitched voice β€” fun and playful

How it works: Uses the Web Audio API to build an audio processing chain (AudioContext β†’ MediaStreamSource β†’ ScriptProcessorNode β†’ MediaStreamDestination) that adjusts pitch/speed of the microphone input in real-time.

  • Voice messages: Select voice mode during recording. The exported .webm file already contains the voice effect β€” recipients cannot restore the original voice, enabling true anonymous messaging
  • 1:1 / Group calls: Tap the voice changer button during a call to cycle through modes. The processed audio track replaces the published LiveKit microphone track.

No server-side configuration is required. The voice changer runs entirely on the client side.


Environment Variables

VariableDescriptionDefault
PORTServer port3000
JWT_SECRETJWT signing key (change in production)dev_secret
DB_HOST / DB_PASS / DB_NAMEMySQL connectionβ€”
REDIS_HOST / REDIS_PASSRedis connectionβ€”
R2_ACCOUNT_IDCloudflare account IDβ€”
R2_ACCESS_KEY_IDR2 API token access keyβ€”
R2_SECRET_ACCESS_KEYR2 API token secret keyβ€”
R2_BUCKETR2 bucket nameβ€”
R2_PUBLIC_URLR2 public base URL (optional)β€”
LIVEKIT_URLPublic LiveKit WebSocket URL used by all callsβ€”
LIVEKIT_API_KEYAPI key shared by the server and LiveKitβ€”
LIVEKIT_API_SECRETAPI secret shared by the server and LiveKitβ€”
VAPID_PUBLIC_KEYWeb Push VAPID public key (optional)β€”
VAPID_PRIVATE_KEYWeb Push VAPID private key (optional)β€”
VAPID_SUBJECTVAPID contact email (optional)mailto:admin@paperphoneplus.app
FCM_PROJECT_IDFirebase project ID (optional, Capacitor Android)β€”
FCM_CLIENT_EMAILFirebase service account email (optional)β€”
FCM_PRIVATE_KEYFirebase service account private key (optional, supports both \n escape and real newlines; see below)β€”
FCM_RELAY_SECRETFCM push relay secret (optional, set on relay host to enable endpoint)β€”
FCM_RELAY_URLFCM push relay URL (optional, self-hosted servers point to relay host)β€”
FCM_RELAY_KEYFCM push relay auth key (optional, must match relay host's FCM_RELAY_SECRET)β€”
ONESIGNAL_APP_IDOneSignal App ID (optional)β€”
ONESIGNAL_REST_KEYOneSignal REST API Key (optional)β€”
ONESIGNAL_RELAY_SECRETOneSignal push relay secret (optional, set on relay host to enable endpoint)β€”
ONESIGNAL_RELAY_URLOneSignal push relay URL (optional, self-hosted servers point to relay host)β€”
ONESIGNAL_RELAY_KEYOneSignal push relay auth key (optional, must match relay host's ONESIGNAL_RELAY_SECRET)β€”
NTFY_BASE_URLntfy server URL (optional, uses public ntfy.sh by default)https://ntfy.sh
NTFY_TOKENntfy auth token (optional, for self-hosted servers)β€”
APNS_TEAM_IDApple Developer Team ID (optional, iOS native push)β€”
APNS_KEY_IDAPNS auth key ID (optional)β€”
APNS_PRIVATE_KEYAPNS .p8 private key content (optional, supports \n escaping)β€”
APNS_BUNDLE_IDiOS App Bundle Identifier (optional)β€”
APNS_SANDBOXAPNS sandbox mode (optional, true for dev/TestFlight)false
APNS_RELAY_SECRETPush relay secret (optional, set on relay host to enable endpoint)β€”
APNS_RELAY_URLPush relay URL (optional, self-hosted servers point to relay host)β€”
APNS_RELAY_KEYPush relay auth key (optional, must match relay host's APNS_RELAY_SECRET)β€”
TELEGRAM_BOT_TOKENTelegram Bot Token (optional)β€”
STICKER_PACKSCustom sticker packs (optional, name:label)13 built-in defaults
ADMIN_PATHAdmin panel URL path/admin
ADMIN_PASSWORDAdmin panel password (change in production)admin123

FCM Private Key Newline Handling

The private_key field in Firebase service account JSON contains an RSA private key in PEM format, which requires real newline characters (\n, ASCII 0x0A) between each 64-character line. However, many deployment platforms (Zeabur, Vercel, Railway, Docker) store environment variables as single-line strings, converting \n into the literal two-character sequence \ + n.

This is the most common cause of FCM push notification failure β€” the PEM parser silently fails and no push notifications are sent, with no error logs.

The server handles this automatically: fcm.rs normalizes literal \n sequences back to real newlines before parsing. Both formats work:

  • Single-line (recommended for cloud platforms): Paste the raw private_key value from the JSON file as-is, with \n escapes:

    FCM_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQ...\n-----END PRIVATE KEY-----\n
    
  • Multi-line (for .env files): Wrap the full PEM content in quotes with real newlines:

    FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
    MIIEvQ...
    -----END PRIVATE KEY-----"
    
PlatformRecommended FormatNotes
ZeaburSingle-line (\n escaped)Paste JSON value directly in Variables panel
Docker / docker-composeEitherUse YAML | for multi-line; single-line in .env
Vercel / RailwaySingle-line (\n escaped)Input fields typically don't support real newlines
Linux .env fileMulti-line (quoted)Ensure quotes are properly closed

Troubleshooting: If FCM variables are set but Android push isn't working, check server logs:

  • [FCM] No access token available β†’ Private key format error (newline issue)
  • [FCM] βœ… Push sent to user xxx β†’ FCM sending works, issue is client-side
  • No FCM logs at all β†’ FCM_PROJECT_ID not set or no token in fcm_tokens table

ntfy Push (Chinese Android Devices without Google Services)

For Android devices without Google Mobile Services (Huawei, Xiaomi, OPPO, vivo, etc.), PaperPhonePlus supports push notifications via ntfy.

Default setup (zero configuration): Uses the public ntfy.sh service. No additional configuration needed.

Optional configuration (for self-hosted ntfy servers):

NTFY_BASE_URL=https://your-ntfy-server.com
NTFY_TOKEN=your_optional_auth_token

User setup flow:

  1. Install the ntfy app (Google Play / F-Droid / Direct Download)
  2. Open PaperPhonePlus Settings and find the "ntfy Push" card
  3. Copy the displayed topic name and subscribe to it in the ntfy app
  4. Tap "Register Push" to complete registration

Security note: ntfy notifications contain notification titles and summaries in plaintext (not the actual message content). For higher security, consider self-hosting an ntfy server.

APNS Push (Native iOS App)

APNS (Apple Push Notification Service) sends push notifications to native iOS apps built with Capacitor. There are two configuration options:

Option A: Direct Configuration (App Developer's Server)

  1. Log in to Apple Developer β†’ Certificates, Identifiers & Profiles β†’ Keys
  2. Click + to create a new Key β†’ check Apple Push Notifications service (APNs) β†’ Register
  3. Download the .p8 file (⚠️ can only be downloaded once!) and note the Key ID
  4. Note your Team ID from the Apple Developer Membership page (10-char alphanumeric)
  5. Add to server/.env:
APNS_TEAM_ID=AB12CD34EF
APNS_KEY_ID=LH4Z9YN3P7
APNS_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGTAgEA...(.p8 file content)...\n-----END PRIVATE KEY-----"
APNS_BUNDLE_ID=com.yourcompany.paperphoneplus
APNS_SANDBOX=false

APNS_SANDBOX: Set to true for development/TestFlight builds, false for App Store production.

Option B: Via Push Relay (Self-Hosted Servers)

If you're using someone else's iOS app (e.g. downloaded from the App Store), you don't have the developer's Apple credentials and cannot send APNS pushes directly. Use the Push Relay instead.

How it works:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Self-hosted server   β”‚  HTTP  β”‚  App developer's server  β”‚  APNS  β”‚  Apple  β”‚
β”‚  (no Apple creds)     │──────→│  (has .p8 Key + Relay)   │──────→│  ──→ πŸ“± β”‚
β”‚                       β”‚       β”‚                          β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚  APNS_RELAY_URL=...   β”‚       β”‚  APNS_TEAM_ID=...        β”‚
β”‚  APNS_RELAY_KEY=...   β”‚       β”‚  APNS_RELAY_SECRET=...   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 1: App developer enables the Relay endpoint

On the app developer's server (which already has APNS credentials), set a relay secret:

# App developer's server .env (already has APNS_TEAM_ID etc.)
APNS_RELAY_SECRET=a_long_random_shared_secret

This automatically enables the push relay endpoint at POST /api/push-relay/apns.

Step 2: Self-hosted user configures the Relay

Self-hosted servers only need two variables β€” no Apple credentials required:

# Self-hosted server .env
APNS_RELAY_URL=https://app-developer-server.com
APNS_RELAY_KEY=the_shared_secret_from_step_1

How it works:

  1. Self-hosted server receives an offline message β†’ queries local apns_tokens table for user's iOS device tokens
  2. Sends device tokens + push title/body via HTTP POST to the Relay
  3. Relay validates the key, then sends to Apple using its own APNS credentials
  4. Relay returns a list of stale tokens; the self-hosted server automatically cleans its local database

Priority: Local APNS credentials β†’ Push Relay β†’ skip (silent). If both are configured, local direct connection takes priority.

Security note: The relay only transmits push notification titles and summaries (e.g. "Someone sent you a message"), not actual message content. Device tokens cannot be used to read user data.

Push Relay (All Channels)

For self-hosted server operators using someone else's published app (e.g. from the App Store/Google Play), you don't have the developer's push credentials (Apple .p8 Key / Firebase service account / OneSignal API Key).

The Push Relay system provides relay capability for APNS, FCM, and OneSignal channels:

App developer enables relay endpoints on their server:

# App developer's server .env
APNS_RELAY_SECRET=a_long_random_string
FCM_RELAY_SECRET=a_long_random_string
ONESIGNAL_RELAY_SECRET=a_long_random_string

Self-hosted users only need relay URL and key β€” no push service credentials required:

# Self-hosted server .env
# APNS (iOS native push)
APNS_RELAY_URL=https://app-developer-server.com
APNS_RELAY_KEY=shared_secret

# FCM (Android native push)
FCM_RELAY_URL=https://app-developer-server.com
FCM_RELAY_KEY=shared_secret

# OneSignal (Median.co-wrapped apps)
ONESIGNAL_RELAY_URL=https://app-developer-server.com
ONESIGNAL_RELAY_KEY=shared_secret

Priority: Local credentials β†’ Push Relay β†’ skip (silent). If both are configured, local direct connection takes priority.


Official Push Relay

Self-hosted server operators can use the official push relay to enable iOS/Android push notifications without configuring any push credentials:

# 2026-05-18
APNS_RELAY_URL=https://619.chat
APNS_RELAY_KEY=EzmpqftbsENaRUO6BTABxLV96q7RuEDyokXJr1DWdDjL54cLg7yXVUQqydCQvxrX
FCM_RELAY_URL=https://619.chat
FCM_RELAY_KEY=EzmpqftbsENaRUO6BTABxLV96q7RuEDyokXJr1DWdDjL54cLg7yXVUQqydCQvxrX
ONESIGNAL_RELAY_URL=https://619.chat
ONESIGNAL_RELAY_KEY=EzmpqftbsENaRUO6BTABxLV96q7RuEDyokXJr1DWdDjL54cLg7yXVUQqydCQvxrX

Add these lines to your self-hosted server's .env file.


License

This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).