mirror of
https://github.com/619dev/PaperPhone.git
synced 2026-05-06 22:12:41 +08:00
增加多国语言说明文档
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **其他语言 / Other Languages:** [English](README_EN.md) · [日本語](README_JA.md) · [한국어](README_KO.md) · [Français](README_FR.md) · [Deutsch](README_DE.md) · [Русский](README_RU.md) · [Español](README_ES.md)
|
||||
|
||||
一款微信风格的端对端加密即时通讯应用,采用无状态 ECDH + XSalsa20-Poly1305 逐消息加密,支持 iOS PWA 永久免签与 Cloudflare R2 文件存储。
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
@@ -19,10 +21,11 @@
|
||||
| 👥 群聊 | 最多 2000 人群组,纯文本消息(无加密),免打扰模式,成员管理 |
|
||||
| ⏱️ 消息自动删除 | 5 档可选(永不/1天/3天/1周/1月),私聊双方均可设置,群聊群主专属 |
|
||||
| 🔔 消息推送 | Web Push (VAPID) + OneSignal 双通道,离线也能收到通知 |
|
||||
| 🌐 多语言 | 中文、英文、日语、韩语、法语(自动检测 + 手动切换) |
|
||||
| 🌐 多语言 | 中文、英文、日语、韩语、法语、德语、俄语、西班牙语(自动检测 + 手动切换) |
|
||||
| 📱 iOS 永久免签 | PWA H5 → Safari「添加到主屏幕」,无需企业证书 |
|
||||
| 💬 消息功能 | 文字、图片、语音消息、Emoji 面板(64 个)、已读状态 |
|
||||
| 🌐 朋友圈 | 发动态(文字+最多9张图)、点赞、评论、好友动态流 |
|
||||
| 🌐 朋友圈 | 发动态(文字+最多9张图)、点赞(显示好友头像)、评论、标签可见性控制 |
|
||||
| 🏷️ 好友标签 | 为好友设置多个标签(12色预设调色板),按标签分类筛选通讯录 |
|
||||
| 🗂️ R2 对象存储 | Cloudflare R2 存储图片/语音,可选公开 CDN 直链 |
|
||||
| 🏗️ 可自托管 | Docker Compose 一键部署,支持 Node.js + Redis 多节点 |
|
||||
|
||||
@@ -285,7 +288,7 @@ paperphone/
|
||||
├── app.js # 路由 + 全局状态 + 来电监听
|
||||
├── api.js # HTTP 客户端
|
||||
├── socket.js # WebSocket 客户端(自动重连)
|
||||
├── i18n.js # 多语言引擎(zh/en/ja/ko/fr)
|
||||
├── i18n.js # 多语言引擎(zh/en/ja/ko/fr/de/ru/es)
|
||||
├── services/
|
||||
│ ├── webrtc.js # WebRTC 管理器(CallManager)
|
||||
│ └── pushNotification.js # 推送订阅管理(Web Push + Median 桥接)
|
||||
|
||||
376
README_DE.md
Normal file
376
README_DE.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **Andere Sprachen / Other Languages:** [中文](README.md) · [English](README_EN.md) · [日本語](README_JA.md) · [한국어](README_KO.md) · [Français](README_FR.md) · [Русский](README_RU.md) · [Español](README_ES.md)
|
||||
|
||||
Eine Instant-Messaging-App im WeChat-Stil mit Ende-zu-Ende-Verschlüsselung über zustandsloses ECDH + XSalsa20-Poly1305 pro Nachricht, Echtzeit-Videoanrufe, Cloudflare R2 Dateispeicher, Mehrsprachigkeit und iOS-PWA-Bereitstellung.
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
|
||||
[](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
|
||||
|
||||
---
|
||||
<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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
[](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
|
||||
|
||||
**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)
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # → http://localhost:3000
|
||||
```
|
||||
|
||||
#### 3. Start the frontend
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. Generate VAPID keys (one-time):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
MIT © PaperPhone Contributors
|
||||
@@ -1,5 +1,7 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **Other Languages:** [中文](README.md) · [日本語](README_JA.md) · [한국어](README_KO.md) · [Français](README_FR.md) · [Deutsch](README_DE.md) · [Русский](README_RU.md) · [Español](README_ES.md)
|
||||
|
||||
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.
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
@@ -18,10 +20,11 @@ A WeChat-style end-to-end encrypted instant messaging app with stateless ECDH +
|
||||
| 👥 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 — auto-detect + manual switch |
|
||||
| 🌐 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, comments |
|
||||
| 🌐 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 |
|
||||
|
||||
@@ -284,7 +287,7 @@ paperphone/
|
||||
├── 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)
|
||||
├── 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)
|
||||
|
||||
376
README_ES.md
Normal file
376
README_ES.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **Otros idiomas / Other Languages:** [中文](README.md) · [English](README_EN.md) · [日本語](README_JA.md) · [한국어](README_KO.md) · [Français](README_FR.md) · [Deutsch](README_DE.md) · [Русский](README_RU.md)
|
||||
|
||||
Una aplicación de mensajería instantánea cifrada de extremo a extremo estilo WeChat, con cifrado ECDH sin estado + XSalsa20-Poly1305 por mensaje, videollamadas en tiempo real, almacenamiento Cloudflare R2, soporte multilingüe y despliegue iOS PWA.
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
|
||||
[](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
|
||||
|
||||
---
|
||||
<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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
[](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
|
||||
|
||||
**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)
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # → http://localhost:3000
|
||||
```
|
||||
|
||||
#### 3. Start the frontend
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. Generate VAPID keys (one-time):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
MIT © PaperPhone Contributors
|
||||
376
README_FR.md
Normal file
376
README_FR.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **Autres langues / Other Languages:** [中文](README.md) · [English](README_EN.md) · [日本語](README_JA.md) · [한국어](README_KO.md) · [Deutsch](README_DE.md) · [Русский](README_RU.md) · [Español](README_ES.md)
|
||||
|
||||
Une application de messagerie instantanée chiffrée de bout en bout, style WeChat, avec chiffrement ECDH sans état + XSalsa20-Poly1305 par message, appels vidéo en temps réel, stockage Cloudflare R2, support multilingue et déploiement iOS PWA.
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
|
||||
[](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
|
||||
|
||||
---
|
||||
<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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
[](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
|
||||
|
||||
**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)
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # → http://localhost:3000
|
||||
```
|
||||
|
||||
#### 3. Start the frontend
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. Generate VAPID keys (one-time):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
MIT © PaperPhone Contributors
|
||||
376
README_JA.md
Normal file
376
README_JA.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **他の言語 / Other Languages:** [中文](README.md) · [English](README_EN.md) · [한국어](README_KO.md) · [Français](README_FR.md) · [Deutsch](README_DE.md) · [Русский](README_RU.md) · [Español](README_ES.md)
|
||||
|
||||
WeChatスタイルのエンドツーエンド暗号化インスタントメッセージアプリ。ステートレス ECDH + XSalsa20-Poly1305 のメッセージ単位暗号化、リアルタイムビデオ通話、Cloudflare R2 ファイルストレージ、多言語対応、iOS PWA デプロイをサポート。
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
|
||||
[](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
|
||||
|
||||
---
|
||||
<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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
[](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
|
||||
|
||||
**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)
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # → http://localhost:3000
|
||||
```
|
||||
|
||||
#### 3. Start the frontend
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. Generate VAPID keys (one-time):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
MIT © PaperPhone Contributors
|
||||
376
README_KO.md
Normal file
376
README_KO.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **다른 언어 / Other Languages:** [中文](README.md) · [English](README_EN.md) · [日本語](README_JA.md) · [Français](README_FR.md) · [Deutsch](README_DE.md) · [Русский](README_RU.md) · [Español](README_ES.md)
|
||||
|
||||
WeChat 스타일의 종단간 암호화 인스턴트 메시징 앱. 무상태 ECDH + XSalsa20-Poly1305 메시지별 암호화, 실시간 영상 통화, Cloudflare R2 파일 저장소, 다국어 지원 및 iOS PWA 배포를 지원합니다.
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
|
||||
[](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
|
||||
|
||||
---
|
||||
<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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
[](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
|
||||
|
||||
**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)
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # → http://localhost:3000
|
||||
```
|
||||
|
||||
#### 3. Start the frontend
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. Generate VAPID keys (one-time):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
MIT © PaperPhone Contributors
|
||||
376
README_RU.md
Normal file
376
README_RU.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# PaperPhone IM
|
||||
|
||||
🌐 **Другие языки / Other Languages:** [中文](README.md) · [English](README_EN.md) · [日本語](README_JA.md) · [한국어](README_KO.md) · [Français](README_FR.md) · [Deutsch](README_DE.md) · [Español](README_ES.md)
|
||||
|
||||
Мессенджер в стиле WeChat со сквозным шифрованием — безсостоянийный ECDH + XSalsa20-Poly1305 для каждого сообщения, видеозвонки в реальном времени, хранилище файлов Cloudflare R2, многоязычная поддержка и развёртывание iOS PWA.
|
||||
|
||||
[](#) [](#) [](#) [](#)
|
||||
|
||||
[](https://zeabur.com/templates/P2J7Y3?referralCode=619dev)
|
||||
|
||||
---
|
||||
<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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
[](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
|
||||
|
||||
**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)
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # → http://localhost:3000
|
||||
```
|
||||
|
||||
#### 3. Start the frontend
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. Generate VAPID keys (one-time):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
MIT © PaperPhone Contributors
|
||||
@@ -1086,13 +1086,655 @@ const TRANSLATIONS = {
|
||||
likedUsers: 'Aimé par',
|
||||
nLikes: 'j\'aime',
|
||||
},
|
||||
|
||||
de: {
|
||||
// App
|
||||
appName: 'PaperPhone',
|
||||
appTagline: 'Ende-zu-Ende-Verschlüssellung · Forward Secrecy · Post-Quantum',
|
||||
keyNotice: 'Schlüssel werden nur auf diesem Gerät gespeichert',
|
||||
// Auth
|
||||
username: 'Benutzername',
|
||||
password: 'Passwort',
|
||||
nickname: 'Anzeigename',
|
||||
login: 'Anmelden',
|
||||
register: 'Registrieren',
|
||||
loggingIn: 'Anmeldung...',
|
||||
generatingKeys: 'Schlüssel werden generiert...',
|
||||
keysRegenerated: 'Schlüssel generiert und hochgeladen',
|
||||
registering: 'Registrierung...',
|
||||
noAccount: 'Kein Konto?',
|
||||
hasAccount: 'Bereits ein Konto?',
|
||||
registerLink: 'Registrieren',
|
||||
loginLink: 'Anmelden',
|
||||
fillFields: 'Bitte Benutzername und Passwort eingeben',
|
||||
opFailed: 'Vorgang fehlgeschlagen',
|
||||
// Tabs
|
||||
tabChats: 'Chats',
|
||||
tabContacts: 'Kontakte',
|
||||
tabDiscover: 'Entdecken',
|
||||
tabMe: 'Ich',
|
||||
// Chats
|
||||
chatsTitle: 'Chats',
|
||||
searchPlaceholder: 'Suchen',
|
||||
noChats: 'Keine Chats',
|
||||
noChatsHint: 'Finde Freunde in den Kontakten und starte einen Chat',
|
||||
tapToChat: 'Tippen zum Chatten',
|
||||
encryptedMsg: 'Verschlüsselte Nachricht',
|
||||
// Chat window
|
||||
inputPlaceholder: 'Nachricht senden...',
|
||||
voiceHint: 'Loslassen zum Senden',
|
||||
sendingVoice: 'Wird gesendet...',
|
||||
uploading: 'Wird hochgeladen...',
|
||||
uploadFailed: 'Hochladen fehlgeschlagen',
|
||||
micFailed: 'Mikrofon nicht verfügbar',
|
||||
encFailed: 'Verschlüsselung fehlgeschlagen',
|
||||
sessionFailed: 'Sicherer Kanal konnte nicht hergestellt werden',
|
||||
imageLabel: '[Bild]',
|
||||
// Contacts
|
||||
contactsTitle: 'Kontakte',
|
||||
friendRequests: 'Freundschaftsanfragen',
|
||||
searchUsers: 'Benutzername suchen',
|
||||
noContacts: 'Noch keine Freunde',
|
||||
noContactsHint: 'Suche nach Benutzernamen, um Freunde hinzuzufügen',
|
||||
noResults: 'Keine Benutzer gefunden',
|
||||
searchFailed: 'Suche fehlgeschlagen',
|
||||
alreadyFriend: 'Bereits befreundet',
|
||||
add: 'Hinzufügen',
|
||||
accept: 'Annehmen',
|
||||
sent: 'Gesendet',
|
||||
requestSent: 'Freundschaftsanfrage gesendet',
|
||||
friendAdded: 'Freund hinzugefügt',
|
||||
opFail: 'Vorgang fehlgeschlagen',
|
||||
online: 'Online',
|
||||
discoverTitle: 'Entdecken',
|
||||
moments: 'Momente',
|
||||
searchFn: 'Suche',
|
||||
news: 'Nachrichten',
|
||||
games: 'Spiele',
|
||||
nearby: 'Leute in der Nähe',
|
||||
shopping: 'Einkaufen',
|
||||
profileTitle: 'Ich',
|
||||
e2eLabel: 'E2E-Verschlüsselung',
|
||||
e2eValue: 'X3DH + Double Ratchet',
|
||||
pqLabel: 'Post-Quantum',
|
||||
pqValue: 'ML-KEM-768',
|
||||
keyFingerprint: 'Schlüsselfingerabdruck anzeigen',
|
||||
changeNickname: 'Anzeigename ändern',
|
||||
language: 'Sprache',
|
||||
version: 'Version',
|
||||
addHomescreen: 'Zum Startbildschirm hinzufügen (iOS)',
|
||||
logout: 'Abmelden',
|
||||
logoutConfirm: 'Von PaperPhone abmelden?',
|
||||
nicknamePrompt: 'Neuen Anzeigenamen eingeben',
|
||||
nicknameUpdated: 'Anzeigename aktualisiert',
|
||||
changeAvatar: 'Avatar ändern',
|
||||
avatarUpdated: 'Avatar aktualisiert',
|
||||
avatarFailed: 'Avatar-Upload fehlgeschlagen',
|
||||
updateFailed: 'Aktualisierung fehlgeschlagen',
|
||||
noKey: 'Kein lokaler Schlüssel gefunden. Bitte erneut anmelden.',
|
||||
fpLabel: 'Schlüsselfingerabdruck (IK)',
|
||||
fpWarning: 'Vergleichen Sie Fingerabdrücke mit Ihrem Kontakt, um MITM-Angriffe auszuschließen',
|
||||
iosInstallTitle: 'Zum Startbildschirm hinzufügen',
|
||||
iosInstallSteps: '1. Diese Seite in Safari öffnen\n2. Teilen-Button (↑) drücken\n3. "Zum Home-Bildschirm" wählen\n4. "Hinzufügen" tippen\n\nFunktioniert wie eine native App!',
|
||||
newMessage: 'Neue Nachricht',
|
||||
newFriendRequest: 'Neue Freundschaftsanfrage erhalten',
|
||||
friendAccepted: 'Freundschaftsanfrage angenommen',
|
||||
comingSoon: 'Demnächst verfügbar',
|
||||
// Calls
|
||||
callVideo: 'Videoanruf',
|
||||
callVoice: 'Sprachanruf',
|
||||
callVideoIncoming: 'Eingehender Videoanruf...',
|
||||
callVoiceIncoming: 'Eingehender Sprachanruf...',
|
||||
callGroupIncoming: 'Gruppenanruf beitreten...',
|
||||
callCalling: 'Anruf läuft...',
|
||||
callAccept: 'Annehmen',
|
||||
callAcceptVideo: 'Video',
|
||||
callAcceptVoice: 'Sprache',
|
||||
callReject: 'Ablehnen',
|
||||
callCancel: 'Abbrechen',
|
||||
callEnd: 'Auflegen',
|
||||
callMute: 'Stumm',
|
||||
callUnmute: 'Laut',
|
||||
callCamera: 'Kamera',
|
||||
callCameraOff: 'Kamera aus',
|
||||
callSwitchCam: 'Kamera wechseln',
|
||||
callSpeaker: 'Lautsprecher',
|
||||
callBusy: 'Bereits in einem Anruf',
|
||||
callMediaFailed: 'Zugriff auf Kamera/Mikrofon nicht möglich',
|
||||
unknownCaller: 'Unbekannter Anrufer',
|
||||
// Moments
|
||||
cancel: 'Abbrechen',
|
||||
newMoment: 'Neuer Beitrag',
|
||||
publish: 'Veröffentlichen',
|
||||
addPhoto: 'Foto hinzufügen',
|
||||
noMoments: 'Keine Beiträge — teile etwas!',
|
||||
momentPlaceholder: 'Was gibt\'s Neues?',
|
||||
deleteConfirm: 'Diesen Beitrag löschen?',
|
||||
sendMoment: 'Wird veröffentlicht...',
|
||||
// Push
|
||||
pushNotifications: 'Benachrichtigungen',
|
||||
pushEnabled: 'Aktiviert',
|
||||
pushDisabled: 'Deaktiviert',
|
||||
pushDenied: 'Blockiert',
|
||||
pushDeniedHint: 'Bitte Benachrichtigungen in den Browsereinstellungen erlauben',
|
||||
pushTurnedOn: 'Benachrichtigungen aktiviert',
|
||||
pushTurnedOff: 'Benachrichtigungen deaktiviert',
|
||||
pushFailed: 'Benachrichtigungen konnten nicht aktiviert werden',
|
||||
// Devices
|
||||
devices: 'Angemeldete Geräte',
|
||||
currentDevice: 'Dieses Gerät',
|
||||
otherDevices: 'Andere Geräte',
|
||||
revokeDevice: 'Abmelden',
|
||||
revokeAllOther: 'Alle anderen Geräte abmelden',
|
||||
revokeConfirm: 'Dieses Gerät abmelden?',
|
||||
revokeAllConfirm: 'Alle Geräte außer diesem abmelden?',
|
||||
sessionRevoked: 'Dieses Gerät wurde abgemeldet',
|
||||
noOtherDevices: 'Keine anderen aktiven Geräte',
|
||||
lastActive: 'Zuletzt aktiv',
|
||||
// Groups
|
||||
tabGroups: 'Gruppen',
|
||||
groupsTitle: 'Gruppen',
|
||||
createGroup: 'Gruppe erstellen',
|
||||
groupName: 'Gruppenname',
|
||||
groupNamePlaceholder: 'Gruppennamen eingeben',
|
||||
selectMembers: 'Mitglieder auswählen',
|
||||
noGroups: 'Keine Gruppen',
|
||||
noGroupsHint: 'Erstelle eine Gruppe, um loszulegen',
|
||||
groupCreated: 'Gruppe erstellt',
|
||||
groupNotice: 'Gruppenankündigung',
|
||||
groupNoNotice: 'Keine Ankündigung',
|
||||
groupMembers: 'Mitglieder',
|
||||
leaveGroup: 'Gruppe verlassen',
|
||||
leaveGroupConfirm: 'Diese Gruppe verlassen?',
|
||||
disbandGroup: 'Gruppe auflösen',
|
||||
disbandGroupConfirm: 'Diese Gruppe auflösen? Dies kann nicht rückgängig gemacht werden.',
|
||||
memberLimitReached: 'Mitgliederlimit erreicht (2000)',
|
||||
groupOwner: 'Inhaber',
|
||||
groupAdmin: 'Admin',
|
||||
nMembers: '',
|
||||
groupInfo: 'Gruppeninfo',
|
||||
addMembers: 'Mitglieder hinzufügen',
|
||||
removeMemberConfirm: 'Dieses Mitglied aus der Gruppe entfernen?',
|
||||
muteGroup: 'Stummschalten',
|
||||
muteEnabled: 'Benachrichtigungen deaktiviert',
|
||||
muteDisabled: 'Benachrichtigungen aktiviert',
|
||||
autoDeleteTitle: 'Nachrichten automatisch löschen',
|
||||
autoDeleteNever: 'Nie',
|
||||
autoDelete1d: 'Nach 1 Tag',
|
||||
autoDelete3d: 'Nach 3 Tagen',
|
||||
autoDelete7d: 'Nach 1 Woche',
|
||||
autoDelete30d: 'Nach 1 Monat',
|
||||
autoDeleteUpdated: 'Auto-Löschung aktualisiert',
|
||||
autoDeleteOwnerOnly: 'Nur der Inhaber kann dies einstellen',
|
||||
// Tags
|
||||
tags: 'Tags',
|
||||
manageTags: 'Tags verwalten',
|
||||
newTag: 'Neuer Tag',
|
||||
editTag: 'Tag bearbeiten',
|
||||
deleteTag: 'Tag löschen',
|
||||
tagColor: 'Tag-Farbe',
|
||||
tagName: 'Tag-Name',
|
||||
tagNamePlaceholder: 'Tag-Namen eingeben',
|
||||
tagDelConfirm: 'Diesen Tag löschen?',
|
||||
noTags: 'Keine Tags',
|
||||
allContacts: 'Alle',
|
||||
setTags: 'Tags setzen',
|
||||
tagExists: 'Tag existiert bereits',
|
||||
tagCreated: 'Tag erstellt',
|
||||
tagUpdated: 'Tag aktualisiert',
|
||||
tagDeleted: 'Tag gelöscht',
|
||||
// Visibility
|
||||
whoCanSee: 'Wer kann das sehen',
|
||||
visibilityPublic: 'Öffentlich',
|
||||
visibilityPublicDesc: 'Für alle Freunde sichtbar',
|
||||
visibilityWhitelist: 'Auswahl',
|
||||
visibilityWhitelistDesc: 'Nur für ausgewählte Freunde/Tags sichtbar',
|
||||
visibilityBlacklist: 'Ausschluss',
|
||||
visibilityBlacklistDesc: 'Für ausgewählte Freunde/Tags ausgeblendet',
|
||||
selectTags: 'Tags auswählen',
|
||||
selectFriends: 'Freunde auswählen',
|
||||
selectedCount: 'ausgewählt',
|
||||
confirm: 'OK',
|
||||
// Likes
|
||||
likedBy: 'Gefällt mir',
|
||||
likedUsers: 'Gefällt',
|
||||
nLikes: 'Likes',
|
||||
},
|
||||
|
||||
ru: {
|
||||
// App
|
||||
appName: 'PaperPhone',
|
||||
appTagline: 'Сквозное шифрование · Прямая секретность · Пост-квантовый',
|
||||
keyNotice: 'Ключи хранятся только на этом устройстве',
|
||||
// Auth
|
||||
username: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
nickname: 'Псевдоним',
|
||||
login: 'Войти',
|
||||
register: 'Регистрация',
|
||||
loggingIn: 'Вход...',
|
||||
generatingKeys: 'Генерация ключей...',
|
||||
keysRegenerated: 'Ключи сгенерированы и загружены',
|
||||
registering: 'Регистрация...',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
hasAccount: 'Уже есть аккаунт?',
|
||||
registerLink: 'Регистрация',
|
||||
loginLink: 'Войти',
|
||||
fillFields: 'Введите имя пользователя и пароль',
|
||||
opFailed: 'Операция не удалась',
|
||||
// Tabs
|
||||
tabChats: 'Чаты',
|
||||
tabContacts: 'Контакты',
|
||||
tabDiscover: 'Поиск',
|
||||
tabMe: 'Я',
|
||||
// Chats
|
||||
chatsTitle: 'Чаты',
|
||||
searchPlaceholder: 'Поиск',
|
||||
noChats: 'Нет чатов',
|
||||
noChatsHint: 'Найдите друзей в контактах, чтобы начать переписку',
|
||||
tapToChat: 'Нажмите, чтобы начать',
|
||||
encryptedMsg: 'Зашифрованное сообщение',
|
||||
// Chat window
|
||||
inputPlaceholder: 'Написать сообщение...',
|
||||
voiceHint: 'Отпустите для отправки',
|
||||
sendingVoice: 'Отправка...',
|
||||
uploading: 'Загрузка...',
|
||||
uploadFailed: 'Не удалось загрузить',
|
||||
micFailed: 'Нет доступа к микрофону',
|
||||
encFailed: 'Ошибка шифрования',
|
||||
sessionFailed: 'Не удалось установить защищённый канал',
|
||||
imageLabel: '[Изображение]',
|
||||
// Contacts
|
||||
contactsTitle: 'Контакты',
|
||||
friendRequests: 'Запросы в друзья',
|
||||
searchUsers: 'Поиск по имени',
|
||||
noContacts: 'Друзей пока нет',
|
||||
noContactsHint: 'Найдите пользователей, чтобы добавить в друзья',
|
||||
noResults: 'Пользователи не найдены',
|
||||
searchFailed: 'Ошибка поиска',
|
||||
alreadyFriend: 'Уже в друзьях',
|
||||
add: 'Добавить',
|
||||
accept: 'Принять',
|
||||
sent: 'Отправлено',
|
||||
requestSent: 'Запрос отправлен',
|
||||
friendAdded: 'Друг добавлен',
|
||||
opFail: 'Операция не удалась',
|
||||
online: 'В сети',
|
||||
discoverTitle: 'Поиск',
|
||||
moments: 'Моменты',
|
||||
searchFn: 'Поиск',
|
||||
news: 'Новости',
|
||||
games: 'Игры',
|
||||
nearby: 'Люди рядом',
|
||||
shopping: 'Покупки',
|
||||
profileTitle: 'Я',
|
||||
e2eLabel: 'Сквозное шифрование',
|
||||
e2eValue: 'X3DH + Double Ratchet',
|
||||
pqLabel: 'Пост-квантовый',
|
||||
pqValue: 'ML-KEM-768',
|
||||
keyFingerprint: 'Показать отпечаток ключа',
|
||||
changeNickname: 'Изменить псевдоним',
|
||||
language: 'Язык',
|
||||
version: 'Версия',
|
||||
addHomescreen: 'Добавить на рабочий стол (iOS)',
|
||||
logout: 'Выйти',
|
||||
logoutConfirm: 'Выйти из PaperPhone?',
|
||||
nicknamePrompt: 'Введите новый псевдоним',
|
||||
nicknameUpdated: 'Псевдоним обновлён',
|
||||
changeAvatar: 'Сменить аватар',
|
||||
avatarUpdated: 'Аватар обновлён',
|
||||
avatarFailed: 'Не удалось загрузить аватар',
|
||||
updateFailed: 'Ошибка обновления',
|
||||
noKey: 'Ключ не найден. Пожалуйста, войдите снова.',
|
||||
fpLabel: 'Отпечаток ключа (IK)',
|
||||
fpWarning: 'Сравните отпечатки с контактом, чтобы исключить атаку MITM',
|
||||
iosInstallTitle: 'Добавить на рабочий стол',
|
||||
iosInstallSteps: '1. Откройте эту страницу в Safari\n2. Нажмите кнопку «Поделиться» (↑)\n3. Выберите «На экран Домой»\n4. Нажмите «Добавить»\n\nРаботает как нативное приложение!',
|
||||
newMessage: 'Новое сообщение',
|
||||
newFriendRequest: 'Новый запрос в друзья',
|
||||
friendAccepted: 'Запрос в друзья принят',
|
||||
comingSoon: 'Скоро',
|
||||
// Calls
|
||||
callVideo: 'Видеозвонок',
|
||||
callVoice: 'Голосовой звонок',
|
||||
callVideoIncoming: 'Входящий видеозвонок...',
|
||||
callVoiceIncoming: 'Входящий голосовой звонок...',
|
||||
callGroupIncoming: 'Присоединение к группе...',
|
||||
callCalling: 'Вызов...',
|
||||
callAccept: 'Ответить',
|
||||
callAcceptVideo: 'Видео',
|
||||
callAcceptVoice: 'Голос',
|
||||
callReject: 'Отклонить',
|
||||
callCancel: 'Отмена',
|
||||
callEnd: 'Завершить',
|
||||
callMute: 'Без звука',
|
||||
callUnmute: 'Со звуком',
|
||||
callCamera: 'Камера',
|
||||
callCameraOff: 'Камера выкл.',
|
||||
callSwitchCam: 'Переключить',
|
||||
callSpeaker: 'Динамик',
|
||||
callBusy: 'Уже идёт вызов',
|
||||
callMediaFailed: 'Нет доступа к камере/микрофону',
|
||||
unknownCaller: 'Неизвестный',
|
||||
// Moments
|
||||
cancel: 'Отмена',
|
||||
newMoment: 'Новый пост',
|
||||
publish: 'Опубликовать',
|
||||
addPhoto: 'Добавить фото',
|
||||
noMoments: 'Пока нет постов — поделитесь чем-нибудь!',
|
||||
momentPlaceholder: 'Что нового?',
|
||||
deleteConfirm: 'Удалить этот пост?',
|
||||
sendMoment: 'Публикация...',
|
||||
// Push
|
||||
pushNotifications: 'Уведомления',
|
||||
pushEnabled: 'Включены',
|
||||
pushDisabled: 'Отключены',
|
||||
pushDenied: 'Заблокированы',
|
||||
pushDeniedHint: 'Разрешите уведомления в настройках браузера',
|
||||
pushTurnedOn: 'Уведомления включены',
|
||||
pushTurnedOff: 'Уведомления отключены',
|
||||
pushFailed: 'Не удалось включить уведомления',
|
||||
// Devices
|
||||
devices: 'Активные сессии',
|
||||
currentDevice: 'Это устройство',
|
||||
otherDevices: 'Другие устройства',
|
||||
revokeDevice: 'Выйти',
|
||||
revokeAllOther: 'Выйти на всех других устройствах',
|
||||
revokeConfirm: 'Выйти на этом устройстве?',
|
||||
revokeAllConfirm: 'Выйти на всех устройствах, кроме этого?',
|
||||
sessionRevoked: 'Сессия завершена',
|
||||
noOtherDevices: 'Нет других активных устройств',
|
||||
lastActive: 'Последняя активность',
|
||||
// Groups
|
||||
tabGroups: 'Группы',
|
||||
groupsTitle: 'Группы',
|
||||
createGroup: 'Создать группу',
|
||||
groupName: 'Название группы',
|
||||
groupNamePlaceholder: 'Введите название группы',
|
||||
selectMembers: 'Выбрать участников',
|
||||
noGroups: 'Нет групп',
|
||||
noGroupsHint: 'Создайте группу, чтобы начать общение',
|
||||
groupCreated: 'Группа создана',
|
||||
groupNotice: 'Объявление группы',
|
||||
groupNoNotice: 'Нет объявлений',
|
||||
groupMembers: 'Участники',
|
||||
leaveGroup: 'Покинуть группу',
|
||||
leaveGroupConfirm: 'Покинуть эту группу?',
|
||||
disbandGroup: 'Расформировать группу',
|
||||
disbandGroupConfirm: 'Расформировать группу? Это действие нельзя отменить.',
|
||||
memberLimitReached: 'Достигнут лимит участников (2000)',
|
||||
groupOwner: 'Владелец',
|
||||
groupAdmin: 'Админ',
|
||||
nMembers: '',
|
||||
groupInfo: 'Информация о группе',
|
||||
addMembers: 'Добавить участников',
|
||||
removeMemberConfirm: 'Удалить этого участника из группы?',
|
||||
muteGroup: 'Без звука',
|
||||
muteEnabled: 'Уведомления отключены',
|
||||
muteDisabled: 'Уведомления включены',
|
||||
autoDeleteTitle: 'Автоудаление сообщений',
|
||||
autoDeleteNever: 'Никогда',
|
||||
autoDelete1d: 'Через 1 день',
|
||||
autoDelete3d: 'Через 3 дня',
|
||||
autoDelete7d: 'Через 1 неделю',
|
||||
autoDelete30d: 'Через 1 месяц',
|
||||
autoDeleteUpdated: 'Автоудаление обновлено',
|
||||
autoDeleteOwnerOnly: 'Только владелец может настроить',
|
||||
// Tags
|
||||
tags: 'Теги',
|
||||
manageTags: 'Управление тегами',
|
||||
newTag: 'Новый тег',
|
||||
editTag: 'Изменить тег',
|
||||
deleteTag: 'Удалить тег',
|
||||
tagColor: 'Цвет тега',
|
||||
tagName: 'Название тега',
|
||||
tagNamePlaceholder: 'Введите название тега',
|
||||
tagDelConfirm: 'Удалить этот тег?',
|
||||
noTags: 'Нет тегов',
|
||||
allContacts: 'Все',
|
||||
setTags: 'Установить теги',
|
||||
tagExists: 'Тег уже существует',
|
||||
tagCreated: 'Тег создан',
|
||||
tagUpdated: 'Тег обновлён',
|
||||
tagDeleted: 'Тег удалён',
|
||||
// Visibility
|
||||
whoCanSee: 'Кто может видеть',
|
||||
visibilityPublic: 'Все',
|
||||
visibilityPublicDesc: 'Видно всем друзьям',
|
||||
visibilityWhitelist: 'Выборочно',
|
||||
visibilityWhitelistDesc: 'Только выбранные друзья/теги',
|
||||
visibilityBlacklist: 'Исключить',
|
||||
visibilityBlacklistDesc: 'Скрыть от выбранных друзей/тегов',
|
||||
selectTags: 'Выбрать теги',
|
||||
selectFriends: 'Выбрать друзей',
|
||||
selectedCount: 'выбрано',
|
||||
confirm: 'ОК',
|
||||
// Likes
|
||||
likedBy: 'Лайк',
|
||||
likedUsers: 'Понравилось',
|
||||
nLikes: 'лайков',
|
||||
},
|
||||
|
||||
es: {
|
||||
// App
|
||||
appName: 'PaperPhone',
|
||||
appTagline: 'Cifrado extremo a extremo · Secreto hacia adelante · Post-cuántico',
|
||||
keyNotice: 'Las claves se almacenan solo en este dispositivo',
|
||||
// Auth
|
||||
username: 'Usuario',
|
||||
password: 'Contraseña',
|
||||
nickname: 'Apodo',
|
||||
login: 'Iniciar sesión',
|
||||
register: 'Registrarse',
|
||||
loggingIn: 'Iniciando sesión...',
|
||||
generatingKeys: 'Generando claves...',
|
||||
keysRegenerated: 'Claves generadas y subidas',
|
||||
registering: 'Registrando...',
|
||||
noAccount: '¿No tienes cuenta?',
|
||||
hasAccount: '¿Ya tienes cuenta?',
|
||||
registerLink: 'Registrarse',
|
||||
loginLink: 'Iniciar sesión',
|
||||
fillFields: 'Ingresa usuario y contraseña',
|
||||
opFailed: 'Operación fallida',
|
||||
// Tabs
|
||||
tabChats: 'Chats',
|
||||
tabContacts: 'Contactos',
|
||||
tabDiscover: 'Descubrir',
|
||||
tabMe: 'Yo',
|
||||
// Chats
|
||||
chatsTitle: 'Chats',
|
||||
searchPlaceholder: 'Buscar',
|
||||
noChats: 'Sin conversaciones',
|
||||
noChatsHint: 'Busca amigos en contactos para iniciar un chat',
|
||||
tapToChat: 'Toca para chatear',
|
||||
encryptedMsg: 'Mensaje cifrado',
|
||||
// Chat window
|
||||
inputPlaceholder: 'Enviar mensaje...',
|
||||
voiceHint: 'Suelta para enviar',
|
||||
sendingVoice: 'Enviando...',
|
||||
uploading: 'Subiendo...',
|
||||
uploadFailed: 'Error al subir',
|
||||
micFailed: 'No se puede acceder al micrófono',
|
||||
encFailed: 'Error de cifrado',
|
||||
sessionFailed: 'No se pudo establecer el canal seguro',
|
||||
imageLabel: '[Imagen]',
|
||||
// Contacts
|
||||
contactsTitle: 'Contactos',
|
||||
friendRequests: 'Solicitudes de amistad',
|
||||
searchUsers: 'Buscar por nombre de usuario',
|
||||
noContacts: 'Aún no tienes amigos',
|
||||
noContactsHint: 'Busca usuarios para agregar amigos',
|
||||
noResults: 'No se encontraron usuarios',
|
||||
searchFailed: 'Error en la búsqueda',
|
||||
alreadyFriend: 'Ya son amigos',
|
||||
add: 'Agregar',
|
||||
accept: 'Aceptar',
|
||||
sent: 'Enviado',
|
||||
requestSent: 'Solicitud enviada',
|
||||
friendAdded: 'Amigo agregado',
|
||||
opFail: 'Operación fallida',
|
||||
online: 'En línea',
|
||||
discoverTitle: 'Descubrir',
|
||||
moments: 'Momentos',
|
||||
searchFn: 'Buscar',
|
||||
news: 'Noticias',
|
||||
games: 'Juegos',
|
||||
nearby: 'Personas cercanas',
|
||||
shopping: 'Compras',
|
||||
profileTitle: 'Yo',
|
||||
e2eLabel: 'Cifrado E2E',
|
||||
e2eValue: 'X3DH + Double Ratchet',
|
||||
pqLabel: 'Post-Cuántico',
|
||||
pqValue: 'ML-KEM-768',
|
||||
keyFingerprint: 'Ver huella digital de la clave',
|
||||
changeNickname: 'Cambiar apodo',
|
||||
language: 'Idioma',
|
||||
version: 'Versión',
|
||||
addHomescreen: 'Añadir a pantalla de inicio (iOS)',
|
||||
logout: 'Cerrar sesión',
|
||||
logoutConfirm: '¿Cerrar sesión en PaperPhone?',
|
||||
nicknamePrompt: 'Ingresa un nuevo apodo',
|
||||
nicknameUpdated: 'Apodo actualizado',
|
||||
changeAvatar: 'Cambiar avatar',
|
||||
avatarUpdated: 'Avatar actualizado',
|
||||
avatarFailed: 'Error al subir avatar',
|
||||
updateFailed: 'Error al actualizar',
|
||||
noKey: 'No se encontró clave local. Por favor, inicia sesión de nuevo.',
|
||||
fpLabel: 'Huella digital de la clave (IK)',
|
||||
fpWarning: 'Compara las huellas con tu contacto para verificar que no haya ataques MITM',
|
||||
iosInstallTitle: 'Añadir a pantalla de inicio',
|
||||
iosInstallSteps: '1. Abre esta página en Safari\n2. Toca el botón Compartir (↑)\n3. Selecciona "Añadir a inicio"\n4. Toca "Añadir"\n\n¡Funciona como una app nativa!',
|
||||
newMessage: 'Nuevo mensaje',
|
||||
newFriendRequest: 'Nueva solicitud de amistad',
|
||||
friendAccepted: 'Solicitud de amistad aceptada',
|
||||
comingSoon: 'Próximamente',
|
||||
// Calls
|
||||
callVideo: 'Videollamada',
|
||||
callVoice: 'Llamada de voz',
|
||||
callVideoIncoming: 'Videollamada entrante...',
|
||||
callVoiceIncoming: 'Llamada de voz entrante...',
|
||||
callGroupIncoming: 'Unirse a la llamada grupal...',
|
||||
callCalling: 'Llamando...',
|
||||
callAccept: 'Contestar',
|
||||
callAcceptVideo: 'Video',
|
||||
callAcceptVoice: 'Voz',
|
||||
callReject: 'Rechazar',
|
||||
callCancel: 'Cancelar',
|
||||
callEnd: 'Colgar',
|
||||
callMute: 'Silenciar',
|
||||
callUnmute: 'Activar sonido',
|
||||
callCamera: 'Cámara',
|
||||
callCameraOff: 'Cámara apagada',
|
||||
callSwitchCam: 'Cambiar cámara',
|
||||
callSpeaker: 'Altavoz',
|
||||
callBusy: 'Ya hay una llamada en curso',
|
||||
callMediaFailed: 'No se puede acceder a la cámara/micrófono',
|
||||
unknownCaller: 'Llamante desconocido',
|
||||
// Moments
|
||||
cancel: 'Cancelar',
|
||||
newMoment: 'Nueva publicación',
|
||||
publish: 'Publicar',
|
||||
addPhoto: 'Añadir foto',
|
||||
noMoments: 'Sin publicaciones — ¡comparte algo!',
|
||||
momentPlaceholder: '¿Qué hay de nuevo?',
|
||||
deleteConfirm: '¿Eliminar esta publicación?',
|
||||
sendMoment: 'Publicando...',
|
||||
// Push
|
||||
pushNotifications: 'Notificaciones',
|
||||
pushEnabled: 'Activadas',
|
||||
pushDisabled: 'Desactivadas',
|
||||
pushDenied: 'Bloqueadas',
|
||||
pushDeniedHint: 'Permite las notificaciones en la configuración del navegador',
|
||||
pushTurnedOn: 'Notificaciones activadas',
|
||||
pushTurnedOff: 'Notificaciones desactivadas',
|
||||
pushFailed: 'No se pudieron activar las notificaciones',
|
||||
// Devices
|
||||
devices: 'Dispositivos conectados',
|
||||
currentDevice: 'Este dispositivo',
|
||||
otherDevices: 'Otros dispositivos',
|
||||
revokeDevice: 'Cerrar sesión',
|
||||
revokeAllOther: 'Cerrar sesión en todos los demás dispositivos',
|
||||
revokeConfirm: '¿Cerrar sesión en este dispositivo?',
|
||||
revokeAllConfirm: '¿Cerrar sesión en todos los dispositivos excepto este?',
|
||||
sessionRevoked: 'Sesión cerrada en este dispositivo',
|
||||
noOtherDevices: 'No hay otros dispositivos activos',
|
||||
lastActive: 'Última actividad',
|
||||
// Groups
|
||||
tabGroups: 'Grupos',
|
||||
groupsTitle: 'Grupos',
|
||||
createGroup: 'Crear grupo',
|
||||
groupName: 'Nombre del grupo',
|
||||
groupNamePlaceholder: 'Ingresa el nombre del grupo',
|
||||
selectMembers: 'Seleccionar miembros',
|
||||
noGroups: 'Sin grupos',
|
||||
noGroupsHint: 'Crea un grupo para empezar a chatear',
|
||||
groupCreated: 'Grupo creado',
|
||||
groupNotice: 'Anuncio del grupo',
|
||||
groupNoNotice: 'Sin anuncios',
|
||||
groupMembers: 'Miembros',
|
||||
leaveGroup: 'Abandonar grupo',
|
||||
leaveGroupConfirm: '¿Abandonar este grupo?',
|
||||
disbandGroup: 'Disolver grupo',
|
||||
disbandGroupConfirm: '¿Disolver este grupo? Esta acción es irreversible.',
|
||||
memberLimitReached: 'Límite de miembros alcanzado (2000)',
|
||||
groupOwner: 'Propietario',
|
||||
groupAdmin: 'Administrador',
|
||||
nMembers: '',
|
||||
groupInfo: 'Info del grupo',
|
||||
addMembers: 'Añadir miembros',
|
||||
removeMemberConfirm: '¿Eliminar a este miembro del grupo?',
|
||||
muteGroup: 'Silenciar',
|
||||
muteEnabled: 'Notificaciones desactivadas',
|
||||
muteDisabled: 'Notificaciones activadas',
|
||||
autoDeleteTitle: 'Eliminación automática de mensajes',
|
||||
autoDeleteNever: 'Nunca',
|
||||
autoDelete1d: 'Después de 1 día',
|
||||
autoDelete3d: 'Después de 3 días',
|
||||
autoDelete7d: 'Después de 1 semana',
|
||||
autoDelete30d: 'Después de 1 mes',
|
||||
autoDeleteUpdated: 'Eliminación automática actualizada',
|
||||
autoDeleteOwnerOnly: 'Solo el propietario puede configurar',
|
||||
// Tags
|
||||
tags: 'Etiquetas',
|
||||
manageTags: 'Gestionar etiquetas',
|
||||
newTag: 'Nueva etiqueta',
|
||||
editTag: 'Editar etiqueta',
|
||||
deleteTag: 'Eliminar etiqueta',
|
||||
tagColor: 'Color de etiqueta',
|
||||
tagName: 'Nombre de etiqueta',
|
||||
tagNamePlaceholder: 'Ingresa el nombre de la etiqueta',
|
||||
tagDelConfirm: '¿Eliminar esta etiqueta?',
|
||||
noTags: 'Sin etiquetas',
|
||||
allContacts: 'Todos',
|
||||
setTags: 'Establecer etiquetas',
|
||||
tagExists: 'La etiqueta ya existe',
|
||||
tagCreated: 'Etiqueta creada',
|
||||
tagUpdated: 'Etiqueta actualizada',
|
||||
tagDeleted: 'Etiqueta eliminada',
|
||||
// Visibility
|
||||
whoCanSee: 'Quién puede ver',
|
||||
visibilityPublic: 'Público',
|
||||
visibilityPublicDesc: 'Visible para todos los amigos',
|
||||
visibilityWhitelist: 'Selección',
|
||||
visibilityWhitelistDesc: 'Visible para amigos/etiquetas seleccionados',
|
||||
visibilityBlacklist: 'Exclusión',
|
||||
visibilityBlacklistDesc: 'Oculto para amigos/etiquetas seleccionados',
|
||||
selectTags: 'Seleccionar etiquetas',
|
||||
selectFriends: 'Seleccionar amigos',
|
||||
selectedCount: 'seleccionados',
|
||||
confirm: 'OK',
|
||||
// Likes
|
||||
likedBy: 'Me gusta',
|
||||
likedUsers: 'Les gusta',
|
||||
nLikes: 'me gusta',
|
||||
},
|
||||
};
|
||||
|
||||
// ── Language Engine ────────────────────────────────────────────────────────
|
||||
|
||||
const SUPPORTED = ['zh', 'en', 'ja', 'ko', 'fr'];
|
||||
const LANG_NAMES = { zh: '中文', en: 'English', ja: '日本語', ko: '한국어', fr: 'Français' };
|
||||
const LANG_FLAGS = { zh: '🇨🇳', en: '🇺🇸', ja: '🇯🇵', ko: '🇰🇷', fr: '🇫🇷' };
|
||||
const SUPPORTED = ['zh', 'en', 'ja', 'ko', 'fr', 'de', 'ru', 'es'];
|
||||
const LANG_NAMES = { zh: '中文', en: 'English', ja: '日本語', ko: '한국어', fr: 'Français', de: 'Deutsch', ru: 'Русский', es: 'Español' };
|
||||
const LANG_FLAGS = { zh: '🇨🇳', en: '🇺🇸', ja: '🇯🇵', ko: '🇰🇷', fr: '🇫🇷', de: '🇩🇪', ru: '🇷🇺', es: '🇪🇸' };
|
||||
|
||||
function detectLang() {
|
||||
const saved = localStorage.getItem('pp_lang');
|
||||
@@ -1102,6 +1744,9 @@ function detectLang() {
|
||||
if (nav.startsWith('ja')) return 'ja';
|
||||
if (nav.startsWith('ko')) return 'ko';
|
||||
if (nav.startsWith('fr')) return 'fr';
|
||||
if (nav.startsWith('de')) return 'de';
|
||||
if (nav.startsWith('ru')) return 'ru';
|
||||
if (nav.startsWith('es')) return 'es';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user