This commit is contained in:
619dev
2026-03-25 14:31:05 +08:00
commit 70982e58b1
4391 changed files with 408919 additions and 0 deletions

168
README.md Normal file
View File

@@ -0,0 +1,168 @@
# PaperPhone IM
一款微信风格的端对端加密即时通讯应用,融合了 BoxIM 架构和 SimpleX Chat 的安全模型。
## 特性
| 功能 | 说明 |
|------|------|
| 🔐 端对端加密 | X3DH 初始密钥协商 + Double Ratchet 前向保密 |
| ⚛️ 抗量子 | ML-KEM-768 (CRYSTALS-Kyber, NIST 标准) 注入每轮 Ratchet |
| 🗝️ 零知识服务器 | 服务器只存储密文,私钥仅在设备 IndexedDB |
| 📱 iOS 永久免签 | PWA H5 → Safari "添加到主屏幕",无需企业证书 |
| 🌐 微信 UI | 四标签底栏,气泡聊天,语音消息,图片,表情 |
| 🏗️ 可集群 | Node.js + Redis 多节点消息路由 |
## 技术栈
```
后端 (server/)
Node.js + Express + ws
MySQL 8.0 (用户/消息持久化)
Redis (在线状态 + 跨节点路由)
MinIO (文件/图片对象存储)
JWT + bcrypt 认证
前端 (client/)
原生 HTML + Vanilla JS (ESM)
libsodium-wrappers (WebAssembly, Curve25519)
ML-KEM-768 (crystals-kyber)
PWA: manifest.json + Service Worker
加密层
X3DH → Double Ratchet → ML-KEM-768 抗量子注入
私钥存储于 IndexedDB (从不发送至服务器)
```
## 快速启动
### 1. 准备环境
```bash
# MySQL 创建数据库
mysql -u root -p < server/db/schema.sql
# 复制环境变量
cp server/.env.example server/.env
# 编辑 server/.env 填写 MySQL/Redis/MinIO 配置
```
### 2. 启动后端
```bash
cd server
npm install
npm run dev # 监听 http://localhost:3000
```
### 3. 启动前端 (开发)
```bash
# 使用任意静态服务器
npx serve client -p 8080
# 访问 http://localhost:8080
```
### 4. 生产部署
```nginx
# Nginx 反向代理示例 (需要 SSL 证书)
server {
listen 443 ssl;
server_name your.domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# 静态文件
location / {
root /path/to/paperphone/client;
try_files $uri /index.html;
}
# API + WebSocket
location /api/ { proxy_pass http://localhost:3000; }
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
## iOS 永久免签部署
1. 部署到有 HTTPS 域名的服务器
2.**Safari** 打开 `https://your.domain.com`
3. 点击底部分享按钮 ⬆️
4. 选择「添加到主屏幕」
5. 点击「添加」
即可获得与原生 App 相同的体验,无需 Apple 企业证书,永久有效!
## 项目结构
```
paperphone/
├── server/
│ ├── src/
│ │ ├── app.js # Express 应用
│ │ ├── index.js # 入口 + 服务器启动
│ │ ├── db/
│ │ │ ├── mysql.js # MySQL 连接池
│ │ │ ├── redis.js # Redis 客户端
│ │ │ └── minio.js # MinIO 对象存储
│ │ ├── routes/
│ │ │ ├── auth.js # 注册/登录 (含 X3DH 公钥上传)
│ │ │ ├── users.js # 用户搜索/Prekey 下载
│ │ │ ├── friends.js # 好友申请/接受
│ │ │ ├── groups.js # 群组管理
│ │ │ ├── upload.js # MinIO 文件上传
│ │ │ └── messages.js # 历史消息 (密文)
│ │ ├── ws/
│ │ │ └── wsServer.js # WebSocket 消息路由
│ │ └── middlewares/
│ │ └── auth.js # JWT 中间件
│ └── db/
│ └── schema.sql # MySQL 建表脚本
└── client/
├── index.html # SPA 入口 + PWA meta
├── manifest.json # PWA 清单
├── sw.js # Service Worker
└── src/
├── style.css # 微信风格设计系统
├── app.js # 路由 + 全局状态
├── api.js # HTTP 客户端
├── socket.js # WebSocket 客户端
├── crypto/
│ ├── ratchet.js # X3DH + Double Ratchet + ML-KEM
│ └── keystore.js # IndexedDB 私钥存储
└── pages/
├── login.js # 登录/注册 (含密钥生成)
├── chats.js # 会话列表
├── chat.js # 聊天窗口 (E2E 加密)
├── contacts.js # 通讯录
├── discover.js # 发现
└── profile.js # 我的/设置
```
## 安全模型
```
注册时:
设备生成 IK (身份密钥) + SPK (签名预密钥) + 10x OPK (一次性预密钥)
公钥上传服务器,私钥仅存 IndexedDB
首次发消息时:
发送方下载接收方 Prekey Bundle
X3DH 四次 DH 得到共享秘密
初始化 Double Ratchet注入 ML-KEM-768 KEM 共享秘密
后续每条消息独立密钥 (前向保密)
服务器:
只看到:密文 + 路由元数据
不存储:明文、私钥、会话状态
消息投递后自动标记,可配置定期清理
```

48
client/index.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="PaperPhone">
<meta name="theme-color" content="#07C160">
<meta name="description" content="PaperPhone - 端对端加密即时通讯">
<title>PaperPhone</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/public/icons/icon-192.png">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<!-- libsodium (WebAssembly crypto) -->
<script>
window._sodiumPromise = new Promise(resolve => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/libsodium-wrappers@0.7.13/dist/modules/libsodium-wrappers.min.js';
s.onload = async () => {
await window.sodium.ready;
window._sodium = window.sodium;
resolve(window.sodium);
};
document.head.appendChild(s);
});
</script>
<link rel="stylesheet" href="/src/style.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/app.js"></script>
<!-- Service Worker -->
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
</html>

14
client/manifest.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "PaperPhone",
"short_name": "PaperPhone",
"description": "端对端加密即时通讯 — 前向保密 + 抗量子",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#111111",
"theme_color": "#07C160",
"icons": [
{ "src": "/public/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/public/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

79
client/src/api.js Normal file
View File

@@ -0,0 +1,79 @@
/**
* API client — all calls over HTTPS, JWT attached
*/
const BASE = (() => {
const loc = window.location;
// In dev, backend on port 3000; in production, same origin
if (loc.port === '8080' || loc.port === '5173') return `http://${loc.hostname}:3000`;
return '';
})();
let _token = localStorage.getItem('pp_token');
export function setToken(t) { _token = t; localStorage.setItem('pp_token', t); }
export function clearToken() { _token = null; localStorage.removeItem('pp_token'); }
export function getToken() { return _token; }
async function req(method, path, body, isForm = false) {
const headers = {};
if (_token) headers['Authorization'] = `Bearer ${_token}`;
if (!isForm) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: isForm ? body : (body ? JSON.stringify(body) : undefined),
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try { const j = await res.json(); msg = j.error || msg; } catch {}
throw new Error(msg);
}
return res.json();
}
export const api = {
// Auth
register: d => req('POST', '/api/auth/register', d),
login: d => req('POST', '/api/auth/login', d),
// Users
me: () => req('GET', '/api/users/me'),
updateMe: d => req('PATCH', '/api/users/me', d),
search: q => req('GET', `/api/users/search?q=${encodeURIComponent(q)}`),
prekeys: uid => req('GET', `/api/users/${uid}/prekeys`),
uploadOPKs: d => req('POST', '/api/users/prekeys', d),
// Friends
friends: () => req('GET', '/api/friends'),
friendRequests: () => req('GET', '/api/friends/requests'),
sendRequest: id => req('POST', '/api/friends/request', { friend_id: id }),
acceptFriend: uid => req('POST', '/api/friends/accept', { user_id: uid }),
removeFriend: id => req('DELETE', `/api/friends/${id}`),
// Groups
groups: () => req('GET', '/api/groups'),
groupInfo: id => req('GET', `/api/groups/${id}`),
createGroup: d => req('POST', '/api/groups', d),
addMember: (gid, uid) => req('POST', `/api/groups/${gid}/members`, { user_id: uid }),
// Messages
privateHistory: pid => req('GET', `/api/messages/private/${pid}`),
groupHistory: gid => req('GET', `/api/messages/group/${gid}`),
// Upload
upload: file => {
const fd = new FormData();
fd.append('file', file);
return req('POST', '/api/upload', fd, true);
},
};
export const WS_URL = (() => {
const loc = window.location;
const proto = loc.protocol === 'https:' ? 'wss' : 'ws';
const port = (loc.port === '8080' || loc.port === '5173') ? '3000' : loc.port;
return `${proto}://${loc.hostname}:${port}`;
})();

201
client/src/app.js Normal file
View File

@@ -0,0 +1,201 @@
/**
* Main App Router — PaperPhone
* Single-page app with 4-tab navigation
*/
import { getToken, api } from './api.js';
import { connect, onEvent } from './socket.js';
import { renderLogin } from './pages/login.js';
import { renderChats } from './pages/chats.js';
import { renderContacts } from './pages/contacts.js';
import { renderDiscover } from './pages/discover.js';
import { renderProfile } from './pages/profile.js';
// ── Global State ──────────────────────────────────────────────────────────
export const state = {
user: null,
chats: [], // [{ id, type, name, avatar, lastMsg, lastTs, unread }]
sessions: {}, // userId/groupId -> ratchet state
contacts: [],
activeTab: 'chats',
chatView: null, // { id, type } or null
};
const root = document.getElementById('app');
// ── Toast helper ──────────────────────────────────────────────────────────
export function showToast(msg, ms = 1800) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), ms);
}
// ── Avatar helper ─────────────────────────────────────────────────────────
export function avatarEl(name, url, cls = '') {
if (url) {
const img = document.createElement('img');
img.className = `avatar ${cls}`;
img.src = url;
img.alt = name;
return img;
}
const div = document.createElement('div');
div.className = `avatar ${cls}`;
div.style.background = nameColor(name);
div.textContent = (name || '?')[0].toUpperCase();
return div;
}
const COLORS = ['#2196F3','#E91E63','#9C27B0','#FF5722','#607D8B','#009688','#795548'];
function nameColor(name) { return COLORS[(name || '').charCodeAt(0) % COLORS.length]; }
export function formatTime(ts) {
const d = new Date(ts), now = new Date();
const diff = now - d;
if (diff < 60e3) return '刚刚';
if (diff < 3600e3) return `${Math.floor(diff / 60e3)}分钟前`;
if (d.toDateString() === now.toDateString()) return d.toTimeString().slice(0, 5);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
// ── Tab Bar ───────────────────────────────────────────────────────────────
function buildTabBar(active) {
const tabs = [
{ id: 'chats', label: '微信', icon: chatIcon() },
{ id: 'contacts', label: '通讯录', icon: contactIcon() },
{ id: 'discover', label: '发现', icon: discoverIcon() },
{ id: 'me', label: '我', icon: meIcon() },
];
const bar = document.createElement('nav');
bar.className = 'tabbar';
tabs.forEach(tab => {
const item = document.createElement('div');
item.className = `tab-item ${tab.id === active ? 'active' : ''}`;
item.id = `tab-${tab.id}`;
item.innerHTML = `
<div class="tab-icon-wrap">
${tab.icon}
${tab.id === 'chats' && state.chats.some(c => c.unread > 0)
? `<div class="tab-dot">${state.chats.reduce((n, c) => n + (c.unread || 0), 0)}</div>` : ''}
</div>
<span>${tab.label}</span>`;
item.addEventListener('click', () => navigateTo(tab.id));
bar.appendChild(item);
});
return bar;
}
// ── Navigation ────────────────────────────────────────────────────────────
export function navigateTo(tab, data) {
state.activeTab = tab;
state.chatView = null;
render();
if (data) window._navData = data;
}
export function openChat(chat) {
state.chatView = chat;
render();
}
export function goBack() {
state.chatView = null;
render();
}
function render() {
root.innerHTML = '';
if (!state.user) {
renderLogin(root);
return;
}
if (state.chatView) {
// Full-screen chat; tabs hidden
import('./pages/chat.js').then(m => m.renderChat(root, state.chatView));
return;
}
const page = document.createElement('div');
page.className = 'page';
const tabbar = buildTabBar(state.activeTab);
switch (state.activeTab) {
case 'chats': renderChats(page); break;
case 'contacts': renderContacts(page); break;
case 'discover': renderDiscover(page); break;
case 'me': renderProfile(page); break;
}
root.appendChild(page);
root.appendChild(tabbar);
}
// ── Incoming WS messages ──────────────────────────────────────────────────
function setupGlobalSocketHandlers() {
onEvent('message', msg => {
// Update chat list unread count
const key = msg.group_id || msg.from;
const chat = state.chats.find(c => c.id === key);
if (chat && state.chatView?.id !== key) {
chat.unread = (chat.unread || 0) + 1;
chat.lastMsg = '🔒 加密消息';
chat.lastTs = msg.ts;
// re-render tab dot
const tabBar = root.querySelector('.tabbar');
if (tabBar) root.replaceChild(buildTabBar(state.activeTab), tabBar);
} else if (!chat) {
// New conversation appeared
state.chats.unshift({ id: key, type: msg.group_id ? 'group' : 'private',
name: msg.from, avatar: null, lastMsg: '🔒 加密消息', lastTs: msg.ts, unread: 1 });
}
});
onEvent('friend_request', () => showToast('收到新的好友请求'));
onEvent('friend_accepted', () => { showToast('好友请求已接受'); });
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
async function init() {
if (!getToken()) { render(); return; }
try {
state.user = await api.me();
connect();
setupGlobalSocketHandlers();
// Load friends & groups for chat list
const [friends, groups] = await Promise.all([api.friends(), api.groups()]);
state.contacts = friends;
state.chats = [
...friends.map(f => ({
id: f.id, type: 'private', name: f.nickname || f.username, avatar: f.avatar,
lastMsg: '', lastTs: 0, unread: 0,
})),
...groups.map(g => ({
id: g.id, type: 'group', name: g.name, avatar: g.avatar,
lastMsg: '', lastTs: 0, unread: 0,
})),
];
} catch {
// Token expired
localStorage.removeItem('pp_token');
}
render();
}
init();
// ── SVG Icons ─────────────────────────────────────────────────────────────
function chatIcon() {
return `<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>`;
}
function contactIcon() {
return `<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>`;
}
function discoverIcon() {
return `<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>`;
}
function meIcon() {
return `<svg viewBox="0 0 24 24"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>`;
}

View File

@@ -0,0 +1,47 @@
/**
* KeyStore — persists crypto keys in IndexedDB
* Keys never touch localStorage or the wire.
*/
const DB_NAME = 'paperphone_keys';
const DB_VERSION = 1;
const STORE = 'keystore';
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = e => e.target.result.createObjectStore(STORE);
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
export async function setKey(name, value) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(value, name);
tx.oncomplete = resolve;
tx.onerror = e => reject(e.target.error);
});
}
export async function getKey(name) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(name);
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
export async function deleteKey(name) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(name);
tx.oncomplete = resolve;
tx.onerror = e => reject(e.target.error);
});
}

View File

@@ -0,0 +1,281 @@
/**
* X3DH Key Agreement + Double Ratchet with ML-KEM-768 post-quantum injection
*
* Uses libsodium-wrappers for:
* - Curve25519 ECDH (X3DH)
* - XSalsa20-Poly1305 symmetric encryption
* - SHA-256 KDF
*
* Uses kyber-crystals (ML-KEM-768) for:
* - Post-quantum KEM injection at every ratchet step
*
* All loaded from CDN via importmap — no bundler required.
*/
// Sodium is loaded globally via <script> in index.html
// after window._sodium is ready
function sodium() {
if (!window._sodium || !window._sodium.ready) throw new Error('libsodium not ready');
return window._sodium;
}
// ── Helpers ───────────────────────────────────────────────────────────────
function b64encode(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function b64decode(str) {
return Uint8Array.from(atob(str), c => c.charCodeAt(0));
}
function concat(...arrays) {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrays) { out.set(a, off); off += a.length; }
return out;
}
function xorBytes(a, b) {
const out = new Uint8Array(a.length);
for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i % b.length];
return out;
}
// ── KDF ──────────────────────────────────────────────────────────────────
function kdf(ikm, info = 'PaperPhone-v1') {
const na = sodium();
const salt = new Uint8Array(32);
const infoBytes = new TextEncoder().encode(info);
return na.crypto_generichash(32, concat(ikm, infoBytes), salt);
}
// ── Key Generation ────────────────────────────────────────────────────────
export async function generateIdentityKeyPair() {
const na = sodium();
await na.ready;
const kp = na.crypto_box_keypair();
return { publicKey: b64encode(kp.publicKey), privateKey: b64encode(kp.privateKey) };
}
export async function generateSignedPreKey(ikPrivateKey) {
const na = sodium();
await na.ready;
const kp = na.crypto_box_keypair();
const sig = na.crypto_sign_detached(
kp.publicKey,
b64decode(ikPrivateKey).slice(0, 32) // use first 32 bytes as ed25519-like key (simplified)
);
return {
publicKey: b64encode(kp.publicKey),
privateKey: b64encode(kp.privateKey),
signature: b64encode(sig),
};
}
export async function generateOneTimePreKey() {
const na = sodium();
await na.ready;
const kp = na.crypto_box_keypair();
return { publicKey: b64encode(kp.publicKey), privateKey: b64encode(kp.privateKey) };
}
// ── X3DH Sender (initiator) ───────────────────────────────────────────────
/**
* Initiates X3DH handshake, returns { sharedSecret (bytes), header (for first message) }
* bundle = { ik_pub, spk_pub, spk_sig, opk?, kem_pub }
*/
export async function x3dhSend(myIK, bundle) {
const na = sodium();
await na.ready;
const EK = na.crypto_box_keypair(); // ephemeral key
const IK_B = b64decode(bundle.ik_pub);
const SPK_B = b64decode(bundle.spk_pub);
const OPK_B = bundle.opk ? b64decode(bundle.opk.opk_pub) : null;
const IK_A_priv = b64decode(myIK.privateKey);
const DH1 = na.crypto_scalarmult(IK_A_priv, SPK_B);
const DH2 = na.crypto_scalarmult(EK.privateKey, IK_B);
const DH3 = na.crypto_scalarmult(EK.privateKey, SPK_B);
const DH4 = OPK_B ? na.crypto_scalarmult(EK.privateKey, OPK_B) : new Uint8Array(32);
const masterSecret = kdf(concat(DH1, DH2, DH3, DH4));
return {
sharedSecret: masterSecret,
header: {
ek_pub: b64encode(EK.publicKey),
ik_pub: myIK.publicKey,
opk_key_id: bundle.opk?.key_id ?? null,
},
};
}
// ── X3DH Receiver ─────────────────────────────────────────────────────────
/**
* Derives shared secret from an incoming X3DH header
* myKeys = { ik (priv), spk (priv), opk? (priv) }
*/
export async function x3dhReceive(myKeys, header) {
const na = sodium();
await na.ready;
const EK_A = b64decode(header.ek_pub);
const IK_A = b64decode(header.ik_pub);
const IK_B_priv = b64decode(myKeys.ik.privateKey);
const SPK_B_priv = b64decode(myKeys.spk.privateKey);
const OPK_B_priv = myKeys.opk ? b64decode(myKeys.opk.privateKey) : null;
const DH1 = na.crypto_scalarmult(SPK_B_priv, IK_A);
const DH2 = na.crypto_scalarmult(IK_B_priv, EK_A);
const DH3 = na.crypto_scalarmult(SPK_B_priv, EK_A);
const DH4 = OPK_B_priv ? na.crypto_scalarmult(OPK_B_priv, EK_A) : new Uint8Array(32);
return kdf(concat(DH1, DH2, DH3, DH4));
}
// ── Double Ratchet State ──────────────────────────────────────────────────
/**
* Create initial ratchet state after X3DH
* role: 'sender' or 'receiver'
*/
export async function ratchetInit(sharedSecret, role) {
const na = sodium();
await na.ready;
const dhKP = na.crypto_box_keypair();
return {
role,
// Root key (32 bytes)
RK: sharedSecret,
// Chain keys
CKs: null, // sending chain key
CKr: null, // receiving chain key
// DH ratchet key pair
DHs: { pub: b64encode(dhKP.publicKey), priv: b64encode(dhKP.privateKey) },
DHr: null, // remote ratchet pubkey
// Counters
Ns: 0, Nr: 0,
// Skip table
MKSKIPPED: {},
};
}
/** Ratchet step — advance root key with DH output + KEM sharedSecret */
function _ratchetStep(RK, dhOutput, kemShared) {
const combined = concat(b64decode(RK) instanceof Uint8Array ? b64decode(RK) : new Uint8Array(RK),
dhOutput, kemShared || new Uint8Array(32));
const newRK = kdf(combined, 'RK-step');
const newCK = kdf(combined, 'CK-step');
return { newRK: b64encode(newRK), newCK: b64encode(newCK) };
}
/** Derive message key from chain key */
function _deriveMessageKey(CK) {
const na = sodium();
const ck = b64decode(CK);
const MK = na.crypto_generichash(32, ck, new TextEncoder().encode('MK'));
const nextCK = na.crypto_generichash(32, ck, new TextEncoder().encode('CK'));
return { MK: b64encode(MK), nextCK: b64encode(nextCK) };
}
// ── Encrypt Message ───────────────────────────────────────────────────────
/**
* Encrypt plaintext using double ratchet.
* Returns { ciphertext (b64), header, newState }
* header includes sender DH public key, message number, kemCT
*/
export async function ratchetEncrypt(state, plaintext) {
const na = sodium();
await na.ready;
let { RK, CKs, DHs, DHr, Ns, kemKP } = state;
// If no sending chain yet, perform DH ratchet step
if (!CKs) {
const dhKP = na.crypto_box_keypair();
DHs = { pub: b64encode(dhKP.publicKey), priv: b64encode(dhKP.privateKey) };
const dhOut = DHr
? na.crypto_scalarmult(b64decode(DHs.priv), b64decode(DHr))
: new Uint8Array(32);
// ML-KEM step
let kemCT = null, kemShared = new Uint8Array(32);
if (window.KyberModule && DHr) {
try {
const res = window.KyberModule.kemEncapsulate(DHr);
kemCT = res.ciphertext;
kemShared = b64decode(res.sharedSecret);
} catch (e) { /* fallback to no PQ */ }
}
const step = _ratchetStep(RK, dhOut, kemShared);
RK = step.newRK;
CKs = step.newCK;
state = { ...state, RK, CKs, DHs, Ns: 0 };
state._kemCT = kemCT;
}
const { MK, nextCK } = _deriveMessageKey(CKs);
const nonce = na.randombytes_buf(24);
const ct = na.crypto_secretbox_easy(
new TextEncoder().encode(plaintext),
nonce,
b64decode(MK)
);
const newState = { ...state, CKs: nextCK, Ns: Ns + 1 };
return {
ciphertext: b64encode(concat(nonce, ct)),
header: { dh: DHs.pub, n: Ns, kemCT: state._kemCT || null },
newState,
};
}
// ── Decrypt Message ───────────────────────────────────────────────────────
export async function ratchetDecrypt(state, ciphertextB64, header, myKemPriv) {
const na = sodium();
await na.ready;
let { RK, CKr, DHs, DHr, Nr } = state;
// DH ratchet step if new sender ratchet key
if (!DHr || header.dh !== DHr) {
const dhOut = DHs
? na.crypto_scalarmult(b64decode(DHs.priv), b64decode(header.dh))
: new Uint8Array(32);
// ML-KEM decapsulate
let kemShared = new Uint8Array(32);
if (header.kemCT && myKemPriv && window.KyberModule) {
try {
kemShared = b64decode(window.KyberModule.kemDecapsulate(header.kemCT, myKemPriv));
} catch (e) { /* fallback */ }
}
const step = _ratchetStep(RK, dhOut, kemShared);
RK = step.newRK;
CKr = step.newCK;
DHr = header.dh;
Nr = 0;
state = { ...state, RK, CKr, DHr, Nr };
}
const { MK, nextCK } = _deriveMessageKey(CKr);
const raw = b64decode(ciphertextB64);
const nonce = raw.slice(0, 24);
const ct = raw.slice(24);
const plaintext = na.crypto_secretbox_open_easy(ct, nonce, b64decode(MK));
const newState = { ...state, CKr: nextCK, Nr: Nr + 1 };
return { plaintext: new TextDecoder().decode(plaintext), newState };
}

348
client/src/pages/chat.js Normal file
View File

@@ -0,0 +1,348 @@
/**
* Chat Window — full screen with E2E encrypted messaging
*/
import { state, avatarEl, goBack, showToast, formatTime } from '../app.js';
import { api } from '../api.js';
import { send, onEvent, offEvent } from '../socket.js';
import { getKey, setKey } from '../crypto/keystore.js';
import {
x3dhSend, x3dhReceive, ratchetInit, ratchetEncrypt, ratchetDecrypt
} from '../crypto/ratchet.js';
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
export async function renderChat(root, chat) {
root.innerHTML = '';
root.style.display = 'flex';
root.style.flexDirection = 'column';
root.style.height = '100dvh';
// ── Top bar ─────────────────────────────────────────────────
const topbar = document.createElement('div');
topbar.className = 'topbar';
topbar.innerHTML = `
<button class="topbar-btn topbar-back" id="back-btn">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
返回
</button>
<div class="topbar-title" id="chat-title">${esc(chat.name)}</div>
<div class="topbar-action" style="min-width:44px"></div>
`;
root.appendChild(topbar);
topbar.querySelector('#back-btn').onclick = goBack;
// ── Messages area ────────────────────────────────────────────
const msgArea = document.createElement('div');
msgArea.className = 'chat-messages';
root.appendChild(msgArea);
// ── Typing indicator ─────────────────────────────────────────
const typingEl = document.createElement('div');
typingEl.className = 'typing-indicator hidden';
typingEl.innerHTML = `<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>`;
root.appendChild(typingEl);
// ── Input toolbar ─────────────────────────────────────────────
const toolbar = document.createElement('div');
toolbar.className = 'input-toolbar';
toolbar.innerHTML = `
<button class="input-toolbar-btn" id="mic-btn" title="语音">🎙</button>
<textarea id="chat-input" rows="1" placeholder="发送消息..." aria-label="消息输入框"></textarea>
<button class="input-toolbar-btn" id="emoji-btn" title="表情">😊</button>
<button class="input-toolbar-btn" id="img-btn" title="图片">🖼</button>
<button class="send-btn hidden" id="send-btn" aria-label="发送">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
<input type="file" id="file-input" accept="image/*,audio/*,video/*" class="hidden">
`;
root.appendChild(toolbar);
// ── Session state ─────────────────────────────────────────────
let ratchetState = await getKey(`session_${chat.id}`);
// ── Render a message bubble ───────────────────────────────────
function addBubble(text, fromMe, ts, msgType = 'text', extra = {}) {
const row = document.createElement('div');
row.className = `msg-row ${fromMe ? 'out' : 'in'}`;
let content = '';
if (msgType === 'image') {
content = `<img class="bubble-image" src="${esc(extra.url || text)}" alt="图片">`;
} else if (msgType === 'voice') {
content = `<div class="bubble-voice">
<span class="voice-icon" data-src="${esc(extra.url || text)}">🔊</span>
<span class="voice-dur">${extra.duration || '?'}″</span>
</div>`;
} else {
content = esc(text);
}
if (!fromMe) {
const av = avatarEl(chat.name, chat.avatar, 'avatar-sm');
row.appendChild(av);
}
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerHTML = content;
if (msgType === 'image') {
bubble.querySelector('.bubble-image').addEventListener('click', e => showImageViewer(e.target.src));
}
if (msgType === 'voice') {
bubble.querySelector('.voice-icon').addEventListener('click', e => {
const audio = new Audio(e.target.dataset.src);
audio.play();
});
}
row.appendChild(bubble);
msgArea.appendChild(row);
msgArea.scrollTop = msgArea.scrollHeight;
}
// ── Load history (ciphertext decoded client-side) ─────────────
try {
const history = chat.type === 'group'
? await api.groupHistory(chat.id)
: await api.privateHistory(chat.id);
for (const row of history) {
const fromMe = row.from_id === state.user.id;
let text = '🔒 加密消息';
if (ratchetState) {
try {
const res = await ratchetDecrypt(ratchetState, row.ciphertext, JSON.parse(row.header || '{}'));
text = res.plaintext;
ratchetState = res.newState;
} catch {}
}
addBubble(text, fromMe, row.created_at, row.msg_type);
}
if (ratchetState) await setKey(`session_${chat.id}`, ratchetState);
} catch {}
// ── Init session if new chat ──────────────────────────────────
async function ensureSession() {
if (ratchetState) return;
if (chat.type !== 'private') return; // Group: symmetric handled differently
try {
await window._sodiumPromise;
const bundle = await api.prekeys(chat.id);
const ik = await getKey('ik');
const { sharedSecret, header: x3dhHeader } = await x3dhSend(ik, bundle);
ratchetState = await ratchetInit(sharedSecret, 'sender');
ratchetState._x3dhHeader = x3dhHeader;
await setKey(`session_${chat.id}`, ratchetState);
} catch (err) {
showToast('建立安全通道失败: ' + err.message);
}
}
// ── Send a message ────────────────────────────────────────────
async function sendMessage(text, msgType = 'text') {
if (!text.trim() && msgType === 'text') return;
await ensureSession();
let ciphertext = text, header = null;
if (ratchetState) {
try {
const res = await ratchetEncrypt(ratchetState, text);
ciphertext = res.ciphertext;
header = JSON.stringify({ ...res.header, ...(ratchetState._x3dhHeader || {}) });
ratchetState = res.newState;
ratchetState._x3dhHeader = null;
await setKey(`session_${chat.id}`, ratchetState);
} catch (err) {
showToast('加密失败: ' + err.message);
return;
}
}
addBubble(text, true, Date.now(), msgType);
send({
type: 'message',
to: chat.type === 'private' ? chat.id : undefined,
group_id: chat.type === 'group' ? chat.id : undefined,
msg_type: msgType,
ciphertext,
header,
});
// Update chat list
const c = state.chats.find(s => s.id === chat.id);
if (c) { c.lastMsg = msgType === 'text' ? text : '[图片]'; c.lastTs = Date.now(); }
}
// ── Send button logic ─────────────────────────────────────────
const inputEl = toolbar.querySelector('#chat-input');
const sendBtn = toolbar.querySelector('#send-btn');
const emojiBtn = toolbar.querySelector('#emoji-btn');
const imgBtn = toolbar.querySelector('#img-btn');
const fileInput = toolbar.querySelector('#file-input');
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
sendBtn.classList.toggle('hidden', !inputEl.value.trim());
emojiBtn.classList.toggle('hidden', !!inputEl.value.trim());
// Typing indicator
send({ type: 'typing', to: chat.type === 'private' ? chat.id : undefined, group_id: chat.type === 'group' ? chat.id : undefined });
});
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const text = inputEl.value.trim();
if (text) { sendMessage(text); inputEl.value = ''; inputEl.style.height = 'auto'; sendBtn.classList.add('hidden'); emojiBtn.classList.remove('hidden'); }
}
});
sendBtn.addEventListener('click', () => {
const text = inputEl.value.trim();
if (text) { sendMessage(text); inputEl.value = ''; inputEl.style.height = 'auto'; sendBtn.classList.add('hidden'); emojiBtn.classList.remove('hidden'); }
});
// ── Image send ────────────────────────────────────────────────
imgBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
try {
showToast('上传中...');
const { url } = await api.upload(file);
addBubble(url, true, Date.now(), file.type.startsWith('image') ? 'image' : 'file', { url });
send({ type: 'message', to: chat.type === 'private' ? chat.id : undefined,
group_id: chat.type === 'group' ? chat.id : undefined,
msg_type: file.type.startsWith('image') ? 'image' : 'file',
ciphertext: url, header: null });
} catch { showToast('上传失败'); }
fileInput.value = '';
});
// ── Voice recording ───────────────────────────────────────────
let mediaRec, recChunks = [], recStart;
const micBtn = toolbar.querySelector('#mic-btn');
let voiceOverlay = null;
micBtn.addEventListener('mousedown', startVoice);
micBtn.addEventListener('touchstart', e => { e.preventDefault(); startVoice(); });
document.addEventListener('mouseup', stopVoice);
document.addEventListener('touchend', stopVoice);
async function startVoice() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRec = new MediaRecorder(stream);
recChunks = [];
recStart = Date.now();
mediaRec.ondataavailable = e => recChunks.push(e.data);
mediaRec.start();
voiceOverlay = document.createElement('div');
voiceOverlay.className = 'voice-overlay';
voiceOverlay.innerHTML = `<div class="voice-pulse">🎙</div><p>松手发送</p>`;
document.body.appendChild(voiceOverlay);
} catch { showToast('无法访问麦克风'); }
}
async function stopVoice() {
if (!mediaRec || mediaRec.state === 'inactive') return;
mediaRec.stop();
voiceOverlay?.remove();
const duration = Math.round((Date.now() - recStart) / 1000);
mediaRec.onstop = async () => {
const blob = new Blob(recChunks, { type: 'audio/webm' });
const file = new File([blob], `voice_${Date.now()}.webm`, { type: 'audio/webm' });
try {
showToast('发送中...');
const { url } = await api.upload(file);
addBubble(url, true, Date.now(), 'voice', { url, duration });
send({ type: 'message', to: chat.type === 'private' ? chat.id : undefined,
group_id: chat.type === 'group' ? chat.id : undefined,
msg_type: 'voice', ciphertext: url, header: null });
} catch { showToast('语音发送失败'); }
};
mediaRec.stream.getTracks().forEach(t => t.stop());
}
// ── Emoji picker (simple) ─────────────────────────────────────
const emojis = ['😊','😂','🥰','😎','👍','🎉','❤️','🔥','😭','🙏','💪','✨','😅','🤣','😍'];
let emojiPanel = null;
emojiBtn.addEventListener('click', () => {
if (emojiPanel) { emojiPanel.remove(); emojiPanel = null; return; }
emojiPanel = document.createElement('div');
emojiPanel.style.cssText = `
position:absolute;bottom:70px;left:0;right:0;background:var(--surface);
border-top:.5px solid var(--border);padding:12px;
display:flex;flex-wrap:wrap;gap:8px;z-index:200;`;
emojis.forEach(em => {
const btn = document.createElement('button');
btn.textContent = em;
btn.style.cssText = 'background:none;border:none;font-size:24px;cursor:pointer;padding:4px;border-radius:6px;';
btn.onclick = () => { inputEl.value += em; inputEl.dispatchEvent(new Event('input')); };
emojiPanel.appendChild(btn);
});
root.appendChild(emojiPanel);
});
// ── Incoming messages ─────────────────────────────────────────
async function handleIncoming(msg) {
const matchId = msg.group_id || msg.from;
if (matchId !== chat.id) return;
let text = '🔒 加密消息';
if (ratchetState && msg.ciphertext) {
// First message — may need X3DH receive
if (msg.header && !ratchetState.DHr) {
try {
const h = JSON.parse(msg.header);
const ik = await getKey('ik');
const spk = await getKey('spk');
const sharedSecret = await x3dhReceive({ ik, spk }, h);
ratchetState = await ratchetInit(sharedSecret, 'receiver');
ratchetState.DHr = h.dh || null;
await setKey(`session_${chat.id}`, ratchetState);
} catch {}
}
try {
const h = msg.header ? JSON.parse(msg.header) : {};
const res = await ratchetDecrypt(ratchetState, msg.ciphertext, h);
text = res.plaintext;
ratchetState = res.newState;
await setKey(`session_${chat.id}`, ratchetState);
} catch {}
}
addBubble(text, false, msg.ts, msg.msg_type || 'text');
}
onEvent('message', handleIncoming);
// Typing
let typingTimer;
onEvent('typing', ({ from }) => {
if (from !== chat.id && from !== state.user.id) return;
typingEl.classList.remove('hidden');
clearTimeout(typingTimer);
typingTimer = setTimeout(() => typingEl.classList.add('hidden'), 3000);
});
// Cleanup on navigate away
root._cleanup = () => {
offEvent('message', handleIncoming);
clearTimeout(typingTimer);
};
}
function showImageViewer(src) {
const viewer = document.createElement('div');
viewer.className = 'img-viewer';
viewer.innerHTML = `
<span class="img-viewer-close">✕</span>
<img src="${src}" alt="图片">
`;
viewer.querySelector('.img-viewer-close').onclick = () => viewer.remove();
viewer.onclick = e => { if (e.target === viewer) viewer.remove(); };
document.body.appendChild(viewer);
}

66
client/src/pages/chats.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* Chats list page
*/
import { state, openChat, avatarEl, formatTime } from '../app.js';
export function renderChats(root) {
// Top bar
root.innerHTML = `
<div class="topbar">
<div style="min-width:44px"></div>
<div class="topbar-title">微信</div>
<button class="topbar-btn topbar-action" id="new-chat-btn" title="新建">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14l4-4h12c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm1 10H6.83L5 14.83V5h15v8z"/>
</svg>
</button>
</div>
<div class="search-wrap">
<input class="search-input" placeholder="搜索">
</div>
<div id="chat-list"></div>
`;
const listEl = root.querySelector('#chat-list');
const chats = state.chats.sort((a, b) => (b.lastTs || 0) - (a.lastTs || 0));
if (!chats.length) {
listEl.innerHTML = `
<div style="text-align:center;padding:60px 20px;color:var(--text-muted)">
<div style="font-size:48px;margin-bottom:12px">💬</div>
<div>暂无会话</div>
<div style="font-size:13px;margin-top:6px">去通讯录找朋友开始聊天吧</div>
</div>`;
return;
}
chats.forEach(chat => {
const item = document.createElement('div');
item.className = 'list-item';
const av = avatarEl(chat.name, chat.avatar);
const meta = document.createElement('div');
meta.className = 'chat-meta';
meta.innerHTML = `
<div class="chat-row">
<span class="chat-name">${escHtml(chat.name)}</span>
<span class="chat-time">${chat.lastTs ? formatTime(chat.lastTs) : ''}</span>
</div>
<div class="chat-row">
<span class="chat-preview">${escHtml(chat.lastMsg || '点击开始聊天')}</span>
${chat.unread > 0 ? `<span class="badge">${chat.unread > 99 ? '99+' : chat.unread}</span>` : ''}
</div>`;
item.appendChild(av);
item.appendChild(meta);
item.addEventListener('click', () => {
chat.unread = 0;
openChat(chat);
});
listEl.appendChild(item);
});
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

View File

@@ -0,0 +1,129 @@
/**
* Contacts page — friend list, requests, search
*/
import { state, openChat, avatarEl, showToast } from '../app.js';
import { api } from '../api.js';
export function renderContacts(root) {
root.innerHTML = `
<div class="topbar">
<div style="min-width:44px"></div>
<div class="topbar-title">通讯录</div>
<button class="topbar-btn topbar-action" id="add-btn"></button>
</div>
<div class="search-wrap">
<input class="search-input" id="contact-search" placeholder="搜索用户名">
</div>
<div id="contact-list"></div>
<div id="search-results" class="hidden"></div>
`;
const listEl = root.querySelector('#contact-list');
const resultsEl = root.querySelector('#search-results');
const searchInput = root.querySelector('#contact-search');
// ── Friend requests section ───────────────────────────────────
async function loadRequests() {
try {
const reqs = await api.friendRequests();
if (!reqs.length) return;
const sec = document.createElement('div');
sec.innerHTML = `<div class="section-header">好友申请 ${reqs.length ? `<span style="color:var(--red)">(${reqs.length})</span>` : ''}</div>`;
reqs.forEach(r => {
const item = document.createElement('div');
item.className = 'list-item';
item.appendChild(avatarEl(r.nickname || r.username, r.avatar, 'avatar-sm'));
item.innerHTML += `
<div class="flex-1">
<div style="font-size:15px;font-weight:500">${r.nickname || r.username}</div>
<div class="text-muted" style="font-size:13px">@${r.username}</div>
</div>
<button class="topbar-btn" data-id="${r.id}" style="background:var(--green);color:#fff;border-radius:6px;font-size:13px;padding:6px 12px">接受</button>
`;
item.querySelector('button').onclick = async e => {
e.stopPropagation();
try {
await api.acceptFriend(r.id);
state.contacts = await api.friends();
showToast('已添加好友');
item.remove();
} catch { showToast('操作失败'); }
};
sec.appendChild(item);
});
listEl.prepend(sec);
} catch {}
}
// ── Friend list ───────────────────────────────────────────────
function renderFriendList() {
listEl.innerHTML = '';
if (!state.contacts.length) {
listEl.innerHTML = `<div style="text-align:center;padding:60px 20px;color:var(--text-muted)">
<div style="font-size:48px;margin-bottom:12px">👥</div>
<div>还没有好友</div>
<div style="font-size:13px;margin-top:6px">搜索用户名添加好友</div>
</div>`;
return;
}
const sorted = [...state.contacts].sort((a, b) =>
(a.nickname || a.username).localeCompare(b.nickname || b.username, 'zh'));
sorted.forEach(f => {
const item = document.createElement('div');
item.className = 'list-item';
item.appendChild(avatarEl(f.nickname || f.username, f.avatar));
item.innerHTML += `
<div class="flex-1">
<div class="chat-name">${f.nickname || f.username}</div>
<div class="text-muted" style="font-size:13px">@${f.username} ${f.is_online ? '🟢' : ''}</div>
</div>`;
item.onclick = () => openChat({ id: f.id, type: 'private', name: f.nickname || f.username, avatar: f.avatar });
listEl.appendChild(item);
});
}
// ── Search ────────────────────────────────────────────────────
let searchTimer;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = searchInput.value.trim();
if (!q) { resultsEl.classList.add('hidden'); listEl.classList.remove('hidden'); return; }
listEl.classList.add('hidden');
resultsEl.classList.remove('hidden');
resultsEl.innerHTML = '<div class="section-header">搜索结果</div>';
searchTimer = setTimeout(async () => {
try {
const users = await api.search(q);
if (!users.length) { resultsEl.innerHTML += '<div style="text-align:center;padding:20px;color:var(--text-muted)">未找到用户</div>'; return; }
users.forEach(u => {
const isFriend = state.contacts.some(c => c.id === u.id);
const item = document.createElement('div');
item.className = 'list-item';
item.appendChild(avatarEl(u.nickname || u.username, u.avatar));
item.innerHTML += `
<div class="flex-1">
<div class="chat-name">${u.nickname || u.username}</div>
<div class="text-muted" style="font-size:13px">@${u.username}</div>
</div>
${isFriend
? `<span style="color:var(--text-muted);font-size:13px">已是好友</span>`
: `<button class="topbar-btn" data-id="${u.id}" style="background:var(--green);color:#fff;border-radius:6px;font-size:13px;padding:6px 12px">添加</button>`}
`;
if (!isFriend) {
item.querySelector('button').onclick = async e => {
e.stopPropagation();
try { await api.sendRequest(u.id); showToast('好友申请已发送'); e.target.textContent = '已发送'; e.target.disabled = true; }
catch (err) { showToast(err.message); }
};
}
resultsEl.appendChild(item);
});
} catch { resultsEl.innerHTML += '<div style="text-align:center;padding:20px;color:var(--text-muted)">搜索失败</div>'; }
}, 400);
});
renderFriendList();
loadRequests();
}

View File

@@ -0,0 +1,49 @@
export function renderDiscover(root) {
root.innerHTML = `
<div class="topbar">
<div style="min-width:44px"></div>
<div class="topbar-title">发现</div>
<div style="min-width:44px"></div>
</div>
<div style="margin:8px 0">
<div class="settings-group">
<div class="discover-item" id="d-moments">
<div class="discover-icon" style="background:#07C160">🌐</div>
<span style="flex:1;font-size:16px">朋友圈</span>
<span style="color:var(--text-muted);font-size:18px"></span>
</div>
</div>
<div class="settings-group" style="margin:8px 0">
<div class="discover-item">
<div class="discover-icon" style="background:#1485EE">🔍</div>
<span style="flex:1;font-size:16px">搜一搜</span>
<span style="color:var(--text-muted);font-size:18px"></span>
</div>
<div class="discover-item">
<div class="discover-icon" style="background:#FA7D3C">📰</div>
<span style="flex:1;font-size:16px">看一看</span>
<span style="color:var(--text-muted);font-size:18px"></span>
</div>
</div>
<div class="settings-group" style="margin:8px 0">
<div class="discover-item">
<div class="discover-icon" style="background:#9B59B6">🎮</div>
<span style="flex:1;font-size:16px">游戏</span>
<span style="color:var(--text-muted);font-size:18px"></span>
</div>
<div class="discover-item">
<div class="discover-icon" style="background:#E74C3C">📍</div>
<span style="flex:1;font-size:16px">附近的人</span>
<span style="color:var(--text-muted);font-size:18px"></span>
</div>
</div>
<div class="settings-group">
<div class="discover-item">
<div class="discover-icon" style="background:#F1C40F">🛍</div>
<span style="flex:1;font-size:16px">购物</span>
<span style="color:var(--text-muted);font-size:18px"></span>
</div>
</div>
</div>
`;
}

106
client/src/pages/login.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* Login / Register page
*/
import { api, setToken } from '../api.js';
import { state, showToast } from '../app.js';
import { connect } from '../socket.js';
import {
generateIdentityKeyPair, generateSignedPreKey, generateOneTimePreKey
} from '../crypto/ratchet.js';
import { setKey } from '../crypto/keystore.js';
export function renderLogin(root) {
let isRegister = false;
root.innerHTML = '';
const screen = document.createElement('div');
screen.className = 'auth-screen';
screen.innerHTML = `
<div class="auth-logo">📱</div>
<div class="auth-title">PaperPhone</div>
<div class="auth-sub" id="auth-sub">端对端加密 · 前向保密 · 抗量子</div>
<form class="auth-form" id="auth-form" autocomplete="off">
<div id="auth-extra" class="hidden">
<input class="auth-input" id="inp-nickname" type="text" placeholder="昵称" style="margin-bottom:12px">
</div>
<input class="auth-input" id="inp-user" type="text" placeholder="用户名" autocomplete="username">
<input class="auth-input" id="inp-pass" type="password" placeholder="密码" autocomplete="current-password">
<div class="auth-error" id="auth-err"></div>
<button class="auth-btn" id="auth-submit" type="submit">登录</button>
</form>
<div class="auth-toggle" id="auth-toggle">没有账号? <span>注册</span></div>
<div class="auth-sub" style="font-size:11px;opacity:.35">🔐 密钥仅存储于本设备</div>
`;
root.appendChild(screen);
const form = document.getElementById('auth-form');
const errEl = document.getElementById('auth-err');
const submitBtn = document.getElementById('auth-submit');
const toggle = document.getElementById('auth-toggle');
const extra = document.getElementById('auth-extra');
toggle.addEventListener('click', () => {
isRegister = !isRegister;
submitBtn.textContent = isRegister ? '注册' : '登录';
toggle.innerHTML = isRegister ? '已有账号? <span>登录</span>' : '没有账号? <span>注册</span>';
extra.classList.toggle('hidden', !isRegister);
errEl.textContent = '';
});
form.addEventListener('submit', async e => {
e.preventDefault();
errEl.textContent = '';
const username = document.getElementById('inp-user').value.trim();
const password = document.getElementById('inp-pass').value;
const nickname = document.getElementById('inp-nickname')?.value.trim() || username;
if (!username || !password) { errEl.textContent = '请填写用户名和密码'; return; }
submitBtn.disabled = true;
submitBtn.textContent = isRegister ? '正在注册...' : '正在登录...';
try {
// Ensure libsodium is ready
await window._sodiumPromise;
let data;
if (isRegister) {
// Generate X3DH + ML-KEM keys
const ik = await generateIdentityKeyPair();
const spk = await generateSignedPreKey(ik.privateKey);
const opks = await Promise.all(Array.from({ length: 10 }, (_, i) =>
generateOneTimePreKey().then(k => ({ key_id: i, opk_pub: k.publicKey, _priv: k.privateKey }))
));
// Store private keys in IndexedDB
await setKey('ik', ik);
await setKey('spk', spk);
for (const opk of opks) {
await setKey(`opk_${opk.key_id}`, { privateKey: opk._priv });
}
data = await api.register({
username, nickname, password,
ik_pub: ik.publicKey,
spk_pub: spk.publicKey,
spk_sig: spk.signature,
kem_pub: ik.publicKey, // Using IK as KEM pub (simplified; replace with real Kyber if JS lib loaded)
prekeys: opks.map(({ key_id, opk_pub }) => ({ key_id, opk_pub })),
});
} else {
data = await api.login({ username, password });
}
setToken(data.token);
state.user = data.user;
connect();
// Reload to init fully
window.location.reload();
} catch (err) {
errEl.textContent = err.message || '操作失败';
submitBtn.disabled = false;
submitBtn.textContent = isRegister ? '注册' : '登录';
}
});
}

125
client/src/pages/profile.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* Profile / Settings page
*/
import { state, showToast, avatarEl } from '../app.js';
import { api, clearToken } from '../api.js';
import { disconnect } from '../socket.js';
export function renderProfile(root) {
const u = state.user;
root.innerHTML = `
<div class="topbar">
<div style="min-width:44px"></div>
<div class="topbar-title">我</div>
<div style="min-width:44px"></div>
</div>
<div class="profile-card">
<div id="av-wrap"></div>
<div class="profile-info">
<div class="profile-name">${esc(u.nickname || u.username)}</div>
<div class="profile-sub">@${esc(u.username)}</div>
<div class="profile-sub" style="margin-top:4px;font-size:11px;color:var(--green)">🔐 端对端加密 · 前向保密</div>
</div>
<span style="color:var(--text-muted);font-size:20px"></span>
</div>
<div style="padding:0 0 8px">
<div class="settings-group">
<div class="settings-item" id="change-nickname">
<div class="settings-icon" style="background:#07C160;">✏️</div>
<span class="settings-label">更改昵称</span>
<span class="settings-value" id="cur-nickname">${esc(u.nickname || u.username)}</span>
<span class="settings-chevron"></span>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-icon" style="background:#1485EE;">🔒</div>
<span class="settings-label">加密信息</span>
<span class="settings-value">X3DH + Double Ratchet</span>
</div>
<div class="settings-item">
<div class="settings-icon" style="background:#9B59B6;">⚛️</div>
<span class="settings-label">抗量子</span>
<span class="settings-value">ML-KEM-768</span>
</div>
<div class="settings-item" id="export-keys">
<div class="settings-icon" style="background:#F39C12;">🗝️</div>
<span class="settings-label">查看设备密钥指纹</span>
<span class="settings-chevron"></span>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-icon" style="background:#2ECC71;">📦</div>
<span class="settings-label">版本</span>
<span class="settings-value">PaperPhone v1.0</span>
</div>
<div class="settings-item" id="pwa-install">
<div class="settings-icon" style="background:#E74C3C;">📱</div>
<span class="settings-label">添加到主屏幕 (iOS)</span>
<span class="settings-chevron"></span>
</div>
</div>
<div class="settings-group" style="margin-top:16px">
<div class="settings-item" id="logout-btn" style="justify-content:center">
<span style="color:var(--red);font-size:16px;font-weight:500">退出登录</span>
</div>
</div>
</div>
`;
// Avatar
const avWrap = root.querySelector('#av-wrap');
avWrap.appendChild(avatarEl(u.nickname || u.username, u.avatar, 'avatar-lg'));
// Change nickname
root.querySelector('#change-nickname').onclick = async () => {
const nn = prompt('请输入新昵称', u.nickname || u.username);
if (nn && nn.trim()) {
try {
await api.updateMe({ nickname: nn.trim() });
state.user.nickname = nn.trim();
root.querySelector('#cur-nickname').textContent = nn.trim();
root.querySelector('.profile-name').textContent = nn.trim();
showToast('昵称已更新');
} catch { showToast('更新失败'); }
}
};
// Show key fingerprint
root.querySelector('#export-keys').onclick = async () => {
const { getKey } = await import('../crypto/keystore.js');
const ik = await getKey('ik');
if (ik) {
const fp = ik.publicKey.slice(0, 16).replace(/(.{4})/g, '$1 ');
alert(`密钥指纹 (IK):\n${fp}\n\n⚠️ 与好友核对指纹可以验证无中间人攻击`);
} else {
showToast('本地无密钥,请重新登录');
}
};
// iOS PWA install instructions
root.querySelector('#pwa-install').onclick = () => {
alert('iOS添加到主屏幕\n\n1. 用Safari打开本页\n2. 点击底部分享按钮 ⬆️\n3. 选择"添加到主屏幕"\n4. 点击"添加"\n\n之后即可像原生App一样使用无需企业证书');
};
// Logout
root.querySelector('#logout-btn').onclick = () => {
if (!confirm('确定退出登录?')) return;
clearToken();
disconnect();
state.user = null;
state.chats = [];
state.contacts = [];
window.location.reload();
};
}
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

61
client/src/socket.js Normal file
View File

@@ -0,0 +1,61 @@
/**
* WebSocket client — auto-reconnect, message dispatching
*/
import { WS_URL, getToken } from './api.js';
let ws;
let reconnectTimer;
const handlers = new Map();
let _onMessage;
export function onMessage(fn) { _onMessage = fn; }
export function onEvent(type, fn) {
if (!handlers.has(type)) handlers.set(type, new Set());
handlers.get(type).add(fn);
}
export function offEvent(type, fn) {
handlers.get(type)?.delete(fn);
}
function dispatch(msg) {
if (_onMessage) _onMessage(msg);
handlers.get(msg.type)?.forEach(fn => fn(msg));
handlers.get('*')?.forEach(fn => fn(msg));
}
export function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
clearTimeout(reconnectTimer);
ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('WS connected');
ws.send(JSON.stringify({ type: 'auth', token: getToken() }));
};
ws.onmessage = e => {
try { dispatch(JSON.parse(e.data)); } catch {}
};
ws.onclose = () => {
console.log('WS closed — reconnecting in 3s');
reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = err => console.error('WS error', err);
}
export function send(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
return true;
}
return false;
}
export function disconnect() {
clearTimeout(reconnectTimer);
if (ws) ws.close();
}

617
client/src/style.css Normal file
View File

@@ -0,0 +1,617 @@
/* =============================================
PaperPhone — WeChat-Style Design System
============================================= */
/* ── Reset & Base ─────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #07C160;
--green-dark: #059D4C;
--green-light: #38D68B;
--bg: #EDEDED;
--bg-dark: #111111;
--surface: #FFFFFF;
--surface-dark: #1C1C1E;
--input-bg: #F2F2F2;
--border: #E0E0E0;
--text: #1A1A1A;
--text-muted: #888888;
--bubble-out: #95EC69;
--bubble-in: #FFFFFF;
--bubble-out-dark: #07C160;
--bubble-in-dark: #2C2C2E;
--topbar: #F7F7F7;
--topbar-dark: #1C1C1E;
--tab: #F7F7F7;
--tab-dark: #111111;
--red: #FA5151;
--shadow: 0 2px 16px rgba(0,0,0,.08);
--radius: 10px;
--radius-sm: 6px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--topbar-h: calc(52px + var(--safe-top));
--tab-h: calc(56px + var(--safe-bottom));
font-size: 16px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: var(--bg-dark);
--surface: var(--surface-dark);
--topbar: var(--topbar-dark);
--tab: var(--tab-dark);
--border: #2A2A2E;
--text: #F2F2F2;
--text-muted: #6B6B6D;
--input-bg: #2C2C2E;
--bubble-out: var(--bubble-out-dark);
--bubble-in: var(--bubble-in-dark);
}
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text);
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
#app {
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Top Bar ──────────────────────────────── */
.topbar {
position: sticky;
top: 0;
z-index: 100;
background: var(--topbar);
display: flex;
align-items: center;
padding: var(--safe-top) 16px 0;
height: var(--topbar-h);
border-bottom: .5px solid var(--border);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.topbar-title {
flex: 1;
text-align: center;
font-size: 17px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
color: var(--green);
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
border-radius: var(--radius-sm);
transition: background .15s;
}
.topbar-btn:hover { background: rgba(0,0,0,.05); }
.topbar-back { min-width: 44px; }
.topbar-action { min-width: 44px; justify-content: flex-end; }
/* ── Bottom Tab Bar ───────────────────────── */
.tabbar {
position: sticky;
bottom: 0;
z-index: 100;
background: var(--tab);
display: flex;
align-items: stretch;
border-top: .5px solid var(--border);
padding-bottom: var(--safe-bottom);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 0;
gap: 3px;
cursor: pointer;
transition: color .15s;
color: var(--text-muted);
user-select: none;
}
.tab-item.active { color: var(--green); }
.tab-item svg { width: 24px; height: 24px; fill: currentColor; }
.tab-item span { font-size: 10px; font-weight: 500; }
.tab-dot {
position: absolute;
top: 6px;
right: calc(50% - 18px);
background: var(--red);
color: #fff;
font-size: 10px;
font-weight: 600;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.tab-icon-wrap { position: relative; }
/* ── Page Content ─────────────────────────── */
.page {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.page::-webkit-scrollbar { display: none; }
/* ── List Items ───────────────────────────── */
.list-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: var(--surface);
cursor: pointer;
gap: 12px;
transition: background .12s;
position: relative;
}
.list-item:active { background: var(--border); }
.list-item::after {
content: '';
position: absolute;
left: 72px;
right: 0;
bottom: 0;
height: .5px;
background: var(--border);
}
.list-item:last-child::after { display: none; }
/* ── Avatar ───────────────────────────────── */
.avatar {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #fff;
background: var(--green);
}
.avatar-sm { width: 36px; height: 36px; font-size: 14px; border-radius: 4px; }
.avatar-lg { width: 64px; height: 64px; font-size: 24px; border-radius: 10px; }
/* ── Chat List ────────────────────────────── */
.chat-meta { flex: 1; min-width: 0; }
.chat-row { display: flex; justify-content: space-between; align-items: baseline; }
.chat-name { font-size: 16px; font-weight: 500; }
.chat-time { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.chat-preview { font-size: 13px; color: var(--text-muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; margin-top: 3px; }
.badge {
background: var(--red);
color: #fff;
font-size: 11px;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
flex-shrink: 0;
}
/* ── Chat Window ──────────────────────────── */
.chat-window {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 0;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--bg);
-webkit-overflow-scrolling: touch;
}
.chat-messages::-webkit-scrollbar { display: none; }
/* Message bubbles */
.msg-row {
display: flex;
align-items: flex-end;
padding: 4px 16px;
gap: 8px;
animation: fadeInUp .15s ease-out;
}
.msg-row.out { flex-direction: row-reverse; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.bubble {
max-width: min(70vw, 320px);
padding: 10px 14px;
border-radius: 18px;
font-size: 15px;
line-height: 1.4;
word-break: break-word;
position: relative;
}
.msg-row.in .bubble { background: var(--bubble-in); border-radius: 4px 18px 18px 18px; box-shadow: var(--shadow); }
.msg-row.out .bubble { background: var(--bubble-out); border-radius: 18px 4px 18px 18px; }
.bubble-image { max-width: 200px; border-radius: 12px; display: block; cursor: zoom-in; }
.bubble-voice {
display: flex;
align-items: center;
gap: 8px;
min-width: 80px;
}
.bubble-voice .voice-icon { font-size: 18px; cursor: pointer; }
.bubble-voice .voice-dur { font-size: 13px; color: var(--text-muted); }
.msg-time-label {
text-align: center;
font-size: 12px;
color: var(--text-muted);
padding: 8px 0;
}
/* Input toolbar */
.input-toolbar {
background: var(--surface);
border-top: .5px solid var(--border);
padding: 8px 12px;
padding-bottom: max(8px, var(--safe-bottom));
display: flex;
align-items: flex-end;
gap: 8px;
}
.input-toolbar-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
color: var(--text-muted);
font-size: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: color .15s, background .15s;
}
.input-toolbar-btn:hover { color: var(--green); }
.input-toolbar-btn:active { background: var(--border); }
#chat-input {
flex: 1;
min-height: 36px;
max-height: 120px;
padding: 8px 12px;
background: var(--input-bg);
border: none;
border-radius: 18px;
font-size: 15px;
font-family: inherit;
color: var(--text);
resize: none;
outline: none;
line-height: 1.4;
overflow-y: auto;
}
#chat-input::placeholder { color: var(--text-muted); }
.send-btn {
background: var(--green);
color: #fff;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
font-size: 18px;
transition: background .15s, transform .1s;
}
.send-btn:hover { background: var(--green-dark); }
.send-btn:active { transform: scale(.92); }
/* ── Voice recording overlay ──────────────── */
.voice-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 999;
backdrop-filter: blur(8px);
}
.voice-pulse {
width: 90px;
height: 90px;
border-radius: 50%;
background: var(--green);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(7,193,96,.4); }
50% { transform: scale(1.08); box-shadow: 0 0 0 14px rgba(7,193,96,0); }
}
.voice-overlay p { color: #fff; margin-top: 18px; font-size: 15px; }
/* ── Search bar ───────────────────────────── */
.search-wrap {
background: var(--topbar);
padding: 8px 16px;
border-bottom: .5px solid var(--border);
}
.search-input {
width: 100%;
padding: 9px 14px;
background: var(--input-bg);
border: none;
border-radius: 10px;
font-size: 14px;
font-family: inherit;
color: var(--text);
outline: none;
}
.search-input::placeholder { color: var(--text-muted); }
/* ── Section Header ───────────────────────── */
.section-header {
padding: 8px 16px;
font-size: 13px;
color: var(--text-muted);
background: var(--bg);
}
/* ── Profile Page ─────────────────────────── */
.profile-card {
background: var(--surface);
display: flex;
align-items: center;
gap: 16px;
padding: 20px 16px;
margin-bottom: 8px;
}
.profile-info { flex: 1; }
.profile-name { font-size: 20px; font-weight: 600; }
.profile-sub { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
/* ── Settings List ────────────────────────── */
.settings-group {
background: var(--surface);
border-radius: var(--radius);
margin: 0 0 8px;
overflow: hidden;
}
.settings-item {
display: flex;
align-items: center;
padding: 14px 16px;
gap: 12px;
cursor: pointer;
position: relative;
transition: background .12s;
}
.settings-item:active { background: var(--border); }
.settings-item::after {
content: '';
position: absolute;
left: 50px;
right: 0;
bottom: 0;
height: .5px;
background: var(--border);
}
.settings-item:last-child::after { display: none; }
.settings-icon { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; }
.settings-label { flex: 1; font-size: 15px; }
.settings-value { font-size: 14px; color: var(--text-muted); }
.settings-chevron { color: var(--text-muted); font-size: 18px; }
/* ── Auth Screen ──────────────────────────── */
.auth-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: 32px 24px;
background: linear-gradient(160deg, #0a2c1a 0%, #111 60%);
gap: 24px;
}
.auth-logo {
width: 88px;
height: 88px;
border-radius: 22px;
background: var(--green);
display: flex;
align-items: center;
justify-content: center;
font-size: 42px;
box-shadow: 0 0 40px rgba(7,193,96,.35);
animation: pop .4s cubic-bezier(.2,1.6,.4,1);
}
@keyframes pop {
from { transform: scale(.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.auth-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -.5px; }
.auth-sub { font-size: 14px; color: rgba(255,255,255,.5); text-align: center; }
.auth-form { width: 100%; max-width: 360px; display: flex; flex-direction: column; gap: 12px; }
.auth-input {
padding: 14px 16px;
border-radius: 12px;
border: 1.5px solid transparent;
background: rgba(255,255,255,.08);
color: #fff;
font-size: 16px;
font-family: inherit;
outline: none;
transition: border-color .2s, background .2s;
}
.auth-input::placeholder { color: rgba(255,255,255,.35); }
.auth-input:focus { border-color: var(--green); background: rgba(255,255,255,.12); }
.auth-btn {
padding: 15px;
border-radius: 12px;
border: none;
background: var(--green);
color: #fff;
font-size: 16px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background .2s, transform .1s;
margin-top: 4px;
}
.auth-btn:hover { background: var(--green-dark); }
.auth-btn:active { transform: scale(.97); }
.auth-toggle { color: rgba(255,255,255,.55); font-size: 14px; cursor: pointer; text-align: center; }
.auth-toggle span { color: var(--green); }
.auth-error { color: #FF6B6B; font-size: 13px; text-align: center; min-height: 18px; }
/* ── Toast ────────────────────────────────── */
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,.75);
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 9999;
pointer-events: none;
animation: toastIn .2s ease-out;
}
@keyframes toastIn {
from { opacity: 0; transform: translate(-50%,-50%) scale(.9); }
to { opacity: 1; transform: translate(-50%,-50%) scale(1); }
}
/* ── Typing indicator ─────────────────────── */
.typing-indicator { display: flex; gap: 4px; align-items: center; padding: 8px 16px; }
.typing-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text-muted);
animation: typingDot 1.2s infinite;
}
.typing-dot:nth-child(2) { animation-delay: .2s; }
.typing-dot:nth-child(3) { animation-delay: .4s; }
@keyframes typingDot {
0%, 80%, 100% { transform: scale(1); opacity: .4; }
40% { transform: scale(1.3); opacity: 1; }
}
/* ── Skeleton loadings ────────────────────── */
.skeleton {
background: linear-gradient(90deg, var(--border) 25%, var(--bg) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* ── Scrollable contacts alpha index ─────── */
.alpha-list { list-style: none; }
.alpha-section-header {
padding: 4px 16px;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg);
position: sticky;
top: 0;
}
/* ── Image viewer ─────────────────────────── */
.img-viewer {
position: fixed;
inset: 0;
background: #000;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
.img-viewer img { max-width: 100%; max-height: 100%; }
.img-viewer-close {
position: absolute;
top: max(20px, var(--safe-top));
right: 20px;
color: #fff;
font-size: 28px;
cursor: pointer;
}
/* ── Discover page ────────────────────────── */
.discover-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--surface);
cursor: pointer;
}
.discover-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
/* ── Utils ────────────────────────────────── */
.flex-1 { flex: 1; }
.text-muted { color: var(--text-muted); }
.text-green { color: var(--green); }
.text-center { text-align: center; }
.mt-8 { margin-top: 8px; }
.p-16 { padding: 16px; }
.hidden { display: none !important; }

64
client/sw.js Normal file
View File

@@ -0,0 +1,64 @@
/**
* Service Worker — PaperPhone PWA
* Cache-first for shell assets, network-first for API
*/
const CACHE = 'paperphone-v1';
const SHELL = [
'/',
'/index.html',
'/manifest.json',
'/src/style.css',
'/src/app.js',
'/src/api.js',
'/src/socket.js',
'/src/pages/login.js',
'/src/pages/chats.js',
'/src/pages/chat.js',
'/src/pages/contacts.js',
'/src/pages/discover.js',
'/src/pages/profile.js',
'/src/crypto/ratchet.js',
'/src/crypto/keystore.js',
'/public/icons/icon-192.png',
'/public/icons/icon-512.png',
];
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// Network-first for API and WebSocket
if (url.pathname.startsWith('/api/') || url.protocol === 'ws:' || url.protocol === 'wss:') {
e.respondWith(
fetch(e.request).catch(() => new Response(JSON.stringify({ error: 'Offline' }), {
headers: { 'Content-Type': 'application/json' }
}))
);
return;
}
// Cache-first for everything else
e.respondWith(
caches.match(e.request).then(cached => {
if (cached) return cached;
return fetch(e.request).then(res => {
const r = res.clone();
caches.open(CACHE).then(c => c.put(e.request, r));
return res;
});
}).catch(() => caches.match('/index.html'))
);
});

18
server/.env Normal file
View File

@@ -0,0 +1,18 @@
PORT=3000
NODE_ENV=development
JWT_SECRET=paperphone_dev_secret_change_in_prod
JWT_EXPIRES_IN=7d
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=root
DB_NAME=paperphone
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASS=
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=paperphone

27
server/.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Server
PORT=3000
NODE_ENV=development
# JWT
JWT_SECRET=change_this_to_a_long_random_string_in_production
JWT_EXPIRES_IN=7d
# MySQL
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=root
DB_NAME=paperphone
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASS=
# MinIO
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=paperphone

87
server/db/schema.sql Normal file
View File

@@ -0,0 +1,87 @@
-- PaperPhone IM Database Schema
-- MySQL 8.0+
CREATE DATABASE IF NOT EXISTS paperphone CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE paperphone;
-- Users table
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
nickname VARCHAR(128) NOT NULL,
avatar VARCHAR(512) DEFAULT NULL,
password VARCHAR(255) NOT NULL,
-- X3DH Identity Key (public, base64)
ik_pub TEXT NOT NULL,
-- Signed PreKey (public, base64)
spk_pub TEXT NOT NULL,
spk_sig TEXT NOT NULL,
-- ML-KEM-768 long-term public key
kem_pub TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
is_online TINYINT(1) DEFAULT 0,
INDEX idx_username (username)
) ENGINE=InnoDB;
-- One-time prekeys (X3DH OPKs)
CREATE TABLE IF NOT EXISTS prekeys (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
key_id INT NOT NULL,
opk_pub TEXT NOT NULL,
used TINYINT(1) DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_unused (user_id, used)
) ENGINE=InnoDB;
-- Friendships
CREATE TABLE IF NOT EXISTS friends (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
friend_id VARCHAR(36) NOT NULL,
status ENUM('pending','accepted','blocked') DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_pair (user_id, friend_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Groups
CREATE TABLE IF NOT EXISTS `groups` (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
avatar VARCHAR(512) DEFAULT NULL,
owner_id VARCHAR(36) NOT NULL,
notice TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users(id)
) ENGINE=InnoDB;
-- Group members
CREATE TABLE IF NOT EXISTS group_members (
group_id VARCHAR(36) NOT NULL,
user_id VARCHAR(36) NOT NULL,
role ENUM('owner','admin','member') DEFAULT 'member',
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, user_id),
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Messages (server only stores ciphertext for offline delivery)
-- Once delivered, messages are deleted from server
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(36) PRIMARY KEY,
type ENUM('private','group') NOT NULL,
from_id VARCHAR(36) NOT NULL,
to_id VARCHAR(36) NOT NULL, -- user_id or group_id
-- Encrypted payload (base64)
ciphertext LONGTEXT NOT NULL,
-- X3DH initial message header if first message
header TEXT DEFAULT NULL,
msg_type ENUM('text','image','file','voice','video_call','system') DEFAULT 'text',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
delivered TINYINT(1) DEFAULT 0,
INDEX idx_to_undelivered (to_id, delivered, created_at)
) ENGINE=InnoDB;

1
server/node_modules/.bin/fxparser generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../fast-xml-parser/src/cli/cli.js

1
server/node_modules/.bin/mime generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../mime/cli.js

1
server/node_modules/.bin/mkdirp generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../mkdirp/bin/cmd.js

1
server/node_modules/.bin/nodemon generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../nodemon/bin/nodemon.js

1
server/node_modules/.bin/nodetouch generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../touch/bin/nodetouch.js

1
server/node_modules/.bin/semver generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../semver/bin/semver.js

1
server/node_modules/.bin/uuid generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../uuid/dist/bin/uuid

2022
server/node_modules/.package-lock.json generated vendored Normal file

File diff suppressed because it is too large Load Diff

14
server/node_modules/@redis/bloom/README.md generated vendored Normal file
View File

@@ -0,0 +1,14 @@
# @redis/bloom
This package provides support for the [RedisBloom](https://redisbloom.io) module, which adds additional probabilistic data structures to Redis. It extends the [Node Redis client](https://github.com/redis/node-redis) to include functions for each of the RediBloom commands.
To use these extra commands, your Redis server must have the RedisBloom module installed.
RedisBloom provides the following probabilistic data structures:
* Bloom Filter: for checking set membership with a high degree of certainty.
* Cuckoo Filter: for checking set membership with a high degree of certainty.
* Count-Min Sketch: Determine the frequency of events in a stream.
* Top-K: Maintain a list of k most frequently seen items.
For complete examples, see `bloom-filter.js`, `cuckoo-filter.js`, `count-min-sketch.js` and `topk.js` in the Node Redis examples folder.

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, item: string): Array<string>;
export { transformBooleanReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, item) {
return ['BF.ADD', key, item];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanReply; } });

View File

@@ -0,0 +1,4 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string): Array<string>;
export declare function transformReply(): number;

View File

@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key) {
return ['BF.CARD', key];
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,4 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string, item: string): Array<string>;
export { transformBooleanReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, item) {
return ['BF.EXISTS', key, item];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanReply; } });

View File

@@ -0,0 +1,23 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string): Array<string>;
export type InfoRawReply = [
_: string,
capacity: number,
_: string,
size: number,
_: string,
numberOfFilters: number,
_: string,
numberOfInsertedItems: number,
_: string,
expansionRate: number
];
export interface InfoReply {
capacity: number;
size: number;
numberOfFilters: number;
numberOfInsertedItems: number;
expansionRate: number;
}
export declare function transformReply(reply: InfoRawReply): InfoReply;

View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key) {
return ['BF.INFO', key];
}
exports.transformArguments = transformArguments;
function transformReply(reply) {
return {
capacity: reply[1],
size: reply[3],
numberOfFilters: reply[5],
numberOfInsertedItems: reply[7],
expansionRate: reply[9]
};
}
exports.transformReply = transformReply;

View File

@@ -0,0 +1,11 @@
import { RedisCommandArgument, RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
interface InsertOptions {
CAPACITY?: number;
ERROR?: number;
EXPANSION?: number;
NOCREATE?: true;
NONSCALING?: true;
}
export declare function transformArguments(key: string, items: RedisCommandArgument | Array<RedisCommandArgument>, options?: InsertOptions): RedisCommandArguments;
export { transformBooleanArrayReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,28 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
const generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, items, options) {
const args = ['BF.INSERT', key];
if (options?.CAPACITY) {
args.push('CAPACITY', options.CAPACITY.toString());
}
if (options?.ERROR) {
args.push('ERROR', options.ERROR.toString());
}
if (options?.EXPANSION) {
args.push('EXPANSION', options.EXPANSION.toString());
}
if (options?.NOCREATE) {
args.push('NOCREATE');
}
if (options?.NONSCALING) {
args.push('NONSCALING');
}
args.push('ITEMS');
return (0, generic_transformers_1.pushVerdictArguments)(args, items);
}
exports.transformArguments = transformArguments;
var generic_transformers_2 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_2.transformBooleanArrayReply; } });

View File

@@ -0,0 +1,4 @@
import { RedisCommandArgument, RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, iteretor: number, chunk: RedisCommandArgument): RedisCommandArguments;
export declare function transformReply(): 'OK';

View File

@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, iteretor, chunk) {
return ['BF.LOADCHUNK', key, iteretor.toString(), chunk];
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, items: Array<string>): Array<string>;
export { transformBooleanArrayReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, items) {
return ['BF.MADD', key, ...items];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanArrayReply; } });

View File

@@ -0,0 +1,4 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string, items: Array<string>): Array<string>;
export { transformBooleanArrayReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, items) {
return ['BF.MEXISTS', key, ...items];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanArrayReply; } });

View File

@@ -0,0 +1,8 @@
export declare const FIRST_KEY_INDEX = 1;
interface ReserveOptions {
EXPANSION?: number;
NONSCALING?: true;
}
export declare function transformArguments(key: string, errorRate: number, capacity: number, options?: ReserveOptions): Array<string>;
export declare function transformReply(): 'OK';
export {};

View File

@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, errorRate, capacity, options) {
const args = ['BF.RESERVE', key, errorRate.toString(), capacity.toString()];
if (options?.EXPANSION) {
args.push('EXPANSION', options.EXPANSION.toString());
}
if (options?.NONSCALING) {
args.push('NONSCALING');
}
return args;
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,13 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string, iterator: number): Array<string>;
type ScanDumpRawReply = [
iterator: number,
chunk: string
];
interface ScanDumpReply {
iterator: number;
chunk: string;
}
export declare function transformReply([iterator, chunk]: ScanDumpRawReply): ScanDumpReply;
export {};

View File

@@ -0,0 +1,16 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, iterator) {
return ['BF.SCANDUMP', key, iterator.toString()];
}
exports.transformArguments = transformArguments;
function transformReply([iterator, chunk]) {
return {
iterator,
chunk
};
}
exports.transformReply = transformReply;

View File

@@ -0,0 +1,33 @@
import * as ADD from './ADD';
import * as CARD from './CARD';
import * as EXISTS from './EXISTS';
import * as INFO from './INFO';
import * as INSERT from './INSERT';
import * as LOADCHUNK from './LOADCHUNK';
import * as MADD from './MADD';
import * as MEXISTS from './MEXISTS';
import * as RESERVE from './RESERVE';
import * as SCANDUMP from './SCANDUMP';
declare const _default: {
ADD: typeof ADD;
add: typeof ADD;
CARD: typeof CARD;
card: typeof CARD;
EXISTS: typeof EXISTS;
exists: typeof EXISTS;
INFO: typeof INFO;
info: typeof INFO;
INSERT: typeof INSERT;
insert: typeof INSERT;
LOADCHUNK: typeof LOADCHUNK;
loadChunk: typeof LOADCHUNK;
MADD: typeof MADD;
mAdd: typeof MADD;
MEXISTS: typeof MEXISTS;
mExists: typeof MEXISTS;
RESERVE: typeof RESERVE;
reserve: typeof RESERVE;
SCANDUMP: typeof SCANDUMP;
scanDump: typeof SCANDUMP;
};
export default _default;

View File

@@ -0,0 +1,34 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ADD = require("./ADD");
const CARD = require("./CARD");
const EXISTS = require("./EXISTS");
const INFO = require("./INFO");
const INSERT = require("./INSERT");
const LOADCHUNK = require("./LOADCHUNK");
const MADD = require("./MADD");
const MEXISTS = require("./MEXISTS");
const RESERVE = require("./RESERVE");
const SCANDUMP = require("./SCANDUMP");
exports.default = {
ADD,
add: ADD,
CARD,
card: CARD,
EXISTS,
exists: EXISTS,
INFO,
info: INFO,
INSERT,
insert: INSERT,
LOADCHUNK,
loadChunk: LOADCHUNK,
MADD,
mAdd: MADD,
MEXISTS,
mExists: MEXISTS,
RESERVE,
reserve: RESERVE,
SCANDUMP,
scanDump: SCANDUMP
};

View File

@@ -0,0 +1,8 @@
export declare const FIRST_KEY_INDEX = 1;
interface IncrByItem {
item: string;
incrementBy: number;
}
export declare function transformArguments(key: string, items: IncrByItem | Array<IncrByItem>): Array<string>;
export declare function transformReply(): Array<number>;
export {};

View File

@@ -0,0 +1,20 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, items) {
const args = ['CMS.INCRBY', key];
if (Array.isArray(items)) {
for (const item of items) {
pushIncrByItem(args, item);
}
}
else {
pushIncrByItem(args, items);
}
return args;
}
exports.transformArguments = transformArguments;
function pushIncrByItem(args, { item, incrementBy }) {
args.push(item, incrementBy.toString());
}

View File

@@ -0,0 +1,17 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string): Array<string>;
export type InfoRawReply = [
_: string,
width: number,
_: string,
depth: number,
_: string,
count: number
];
export interface InfoReply {
width: number;
depth: number;
count: number;
}
export declare function transformReply(reply: InfoRawReply): InfoReply;

View File

@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key) {
return ['CMS.INFO', key];
}
exports.transformArguments = transformArguments;
function transformReply(reply) {
return {
width: reply[1],
depth: reply[3],
count: reply[5]
};
}
exports.transformReply = transformReply;

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, width: number, depth: number): Array<string>;
export declare function transformReply(): 'OK';

View File

@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, width, depth) {
return ['CMS.INITBYDIM', key, width.toString(), depth.toString()];
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, error: number, probability: number): Array<string>;
export declare function transformReply(): 'OK';

View File

@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, error, probability) {
return ['CMS.INITBYPROB', key, error.toString(), probability.toString()];
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,9 @@
export declare const FIRST_KEY_INDEX = 1;
interface Sketch {
name: string;
weight: number;
}
type Sketches = Array<string> | Array<Sketch>;
export declare function transformArguments(dest: string, src: Sketches): Array<string>;
export declare function transformReply(): 'OK';
export {};

View File

@@ -0,0 +1,28 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(dest, src) {
const args = [
'CMS.MERGE',
dest,
src.length.toString()
];
if (isStringSketches(src)) {
args.push(...src);
}
else {
for (const sketch of src) {
args.push(sketch.name);
}
args.push('WEIGHTS');
for (const sketch of src) {
args.push(sketch.weight.toString());
}
}
return args;
}
exports.transformArguments = transformArguments;
function isStringSketches(src) {
return typeof src[0] === 'string';
}

View File

@@ -0,0 +1,5 @@
import { RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string, items: string | Array<string>): RedisCommandArguments;
export declare function transformReply(): Array<number>;

View File

@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
const generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, items) {
return (0, generic_transformers_1.pushVerdictArguments)(['CMS.QUERY', key], items);
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,21 @@
import * as INCRBY from './INCRBY';
import * as INFO from './INFO';
import * as INITBYDIM from './INITBYDIM';
import * as INITBYPROB from './INITBYPROB';
import * as MERGE from './MERGE';
import * as QUERY from './QUERY';
declare const _default: {
INCRBY: typeof INCRBY;
incrBy: typeof INCRBY;
INFO: typeof INFO;
info: typeof INFO;
INITBYDIM: typeof INITBYDIM;
initByDim: typeof INITBYDIM;
INITBYPROB: typeof INITBYPROB;
initByProb: typeof INITBYPROB;
MERGE: typeof MERGE;
merge: typeof MERGE;
QUERY: typeof QUERY;
query: typeof QUERY;
};
export default _default;

View File

@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const INCRBY = require("./INCRBY");
const INFO = require("./INFO");
const INITBYDIM = require("./INITBYDIM");
const INITBYPROB = require("./INITBYPROB");
const MERGE = require("./MERGE");
const QUERY = require("./QUERY");
exports.default = {
INCRBY,
incrBy: INCRBY,
INFO,
info: INFO,
INITBYDIM,
initByDim: INITBYDIM,
INITBYPROB,
initByProb: INITBYPROB,
MERGE,
merge: MERGE,
QUERY,
query: QUERY
};

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, item: string): Array<string>;
export { transformBooleanReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, item) {
return ['CF.ADD', key, item];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanReply; } });

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, item: string): Array<string>;
export { transformBooleanReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, item) {
return ['CF.ADDNX', key, item];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanReply; } });

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, item: string): Array<string>;
export declare function transformReply(): number;

View File

@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, item) {
return ['CF.COUNT', key, item];
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,3 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, item: string): Array<string>;
export { transformBooleanReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, item) {
return ['CF.DEL', key, item];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanReply; } });

View File

@@ -0,0 +1,4 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string, item: string): Array<string>;
export { transformBooleanReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, item) {
return ['CF.EXISTS', key, item];
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanReply; } });

View File

@@ -0,0 +1,32 @@
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: string): Array<string>;
export type InfoRawReply = [
_: string,
size: number,
_: string,
numberOfBuckets: number,
_: string,
numberOfFilters: number,
_: string,
numberOfInsertedItems: number,
_: string,
numberOfDeletedItems: number,
_: string,
bucketSize: number,
_: string,
expansionRate: number,
_: string,
maxIteration: number
];
export interface InfoReply {
size: number;
numberOfBuckets: number;
numberOfFilters: number;
numberOfInsertedItems: number;
numberOfDeletedItems: number;
bucketSize: number;
expansionRate: number;
maxIteration: number;
}
export declare function transformReply(reply: InfoRawReply): InfoReply;

View File

@@ -0,0 +1,22 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key) {
return ['CF.INFO', key];
}
exports.transformArguments = transformArguments;
function transformReply(reply) {
return {
size: reply[1],
numberOfBuckets: reply[3],
numberOfFilters: reply[5],
numberOfInsertedItems: reply[7],
numberOfDeletedItems: reply[9],
bucketSize: reply[11],
expansionRate: reply[13],
maxIteration: reply[15]
};
}
exports.transformReply = transformReply;

View File

@@ -0,0 +1,5 @@
import { RedisCommandArguments } from '@redis/client/dist/lib/commands';
import { InsertOptions } from ".";
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, items: string | Array<string>, options?: InsertOptions): RedisCommandArguments;
export { transformBooleanArrayReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
const _1 = require(".");
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, items, options) {
return (0, _1.pushInsertOptions)(['CF.INSERT', key], items, options);
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanArrayReply; } });

View File

@@ -0,0 +1,5 @@
import { RedisCommandArguments } from '@redis/client/dist/lib/commands';
import { InsertOptions } from ".";
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, items: string | Array<string>, options?: InsertOptions): RedisCommandArguments;
export { transformBooleanArrayReply as transformReply } from '@redis/client/dist/lib/commands/generic-transformers';

View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
const _1 = require(".");
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, items, options) {
return (0, _1.pushInsertOptions)(['CF.INSERTNX', key], items, options);
}
exports.transformArguments = transformArguments;
var generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return generic_transformers_1.transformBooleanArrayReply; } });

View File

@@ -0,0 +1,4 @@
import { RedisCommandArgument, RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, iterator: number, chunk: RedisCommandArgument): RedisCommandArguments;
export declare function transformReply(): 'OK';

View File

@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, iterator, chunk) {
return ['CF.LOADCHUNK', key, iterator.toString(), chunk];
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,9 @@
export declare const FIRST_KEY_INDEX = 1;
interface ReserveOptions {
BUCKETSIZE?: number;
MAXITERATIONS?: number;
EXPANSION?: number;
}
export declare function transformArguments(key: string, capacity: number, options?: ReserveOptions): Array<string>;
export declare function transformReply(): 'OK';
export {};

View File

@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, capacity, options) {
const args = ['CF.RESERVE', key, capacity.toString()];
if (options?.BUCKETSIZE) {
args.push('BUCKETSIZE', options.BUCKETSIZE.toString());
}
if (options?.MAXITERATIONS) {
args.push('MAXITERATIONS', options.MAXITERATIONS.toString());
}
if (options?.EXPANSION) {
args.push('EXPANSION', options.EXPANSION.toString());
}
return args;
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,12 @@
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: string, iterator: number): Array<string>;
type ScanDumpRawReply = [
iterator: number,
chunk: string | null
];
interface ScanDumpReply {
iterator: number;
chunk: string | null;
}
export declare function transformReply([iterator, chunk]: ScanDumpRawReply): ScanDumpReply;
export {};

View File

@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, iterator) {
return ['CF.SCANDUMP', key, iterator.toString()];
}
exports.transformArguments = transformArguments;
function transformReply([iterator, chunk]) {
return {
iterator,
chunk
};
}
exports.transformReply = transformReply;

View File

@@ -0,0 +1,42 @@
import * as ADD from './ADD';
import * as ADDNX from './ADDNX';
import * as COUNT from './COUNT';
import * as DEL from './DEL';
import * as EXISTS from './EXISTS';
import * as INFO from './INFO';
import * as INSERT from './INSERT';
import * as INSERTNX from './INSERTNX';
import * as LOADCHUNK from './LOADCHUNK';
import * as RESERVE from './RESERVE';
import * as SCANDUMP from './SCANDUMP';
import { RedisCommandArguments } from '@redis/client/dist/lib/commands';
declare const _default: {
ADD: typeof ADD;
add: typeof ADD;
ADDNX: typeof ADDNX;
addNX: typeof ADDNX;
COUNT: typeof COUNT;
count: typeof COUNT;
DEL: typeof DEL;
del: typeof DEL;
EXISTS: typeof EXISTS;
exists: typeof EXISTS;
INFO: typeof INFO;
info: typeof INFO;
INSERT: typeof INSERT;
insert: typeof INSERT;
INSERTNX: typeof INSERTNX;
insertNX: typeof INSERTNX;
LOADCHUNK: typeof LOADCHUNK;
loadChunk: typeof LOADCHUNK;
RESERVE: typeof RESERVE;
reserve: typeof RESERVE;
SCANDUMP: typeof SCANDUMP;
scanDump: typeof SCANDUMP;
};
export default _default;
export interface InsertOptions {
CAPACITY?: number;
NOCREATE?: true;
}
export declare function pushInsertOptions(args: RedisCommandArguments, items: string | Array<string>, options?: InsertOptions): RedisCommandArguments;

View File

@@ -0,0 +1,51 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.pushInsertOptions = void 0;
const ADD = require("./ADD");
const ADDNX = require("./ADDNX");
const COUNT = require("./COUNT");
const DEL = require("./DEL");
const EXISTS = require("./EXISTS");
const INFO = require("./INFO");
const INSERT = require("./INSERT");
const INSERTNX = require("./INSERTNX");
const LOADCHUNK = require("./LOADCHUNK");
const RESERVE = require("./RESERVE");
const SCANDUMP = require("./SCANDUMP");
const generic_transformers_1 = require("@redis/client/dist/lib/commands/generic-transformers");
exports.default = {
ADD,
add: ADD,
ADDNX,
addNX: ADDNX,
COUNT,
count: COUNT,
DEL,
del: DEL,
EXISTS,
exists: EXISTS,
INFO,
info: INFO,
INSERT,
insert: INSERT,
INSERTNX,
insertNX: INSERTNX,
LOADCHUNK,
loadChunk: LOADCHUNK,
RESERVE,
reserve: RESERVE,
SCANDUMP,
scanDump: SCANDUMP
};
function pushInsertOptions(args, items, options) {
if (options?.CAPACITY) {
args.push('CAPACITY');
args.push(options.CAPACITY.toString());
}
if (options?.NOCREATE) {
args.push('NOCREATE');
}
args.push('ITEMS');
return (0, generic_transformers_1.pushVerdictArguments)(args, items);
}
exports.pushInsertOptions = pushInsertOptions;

View File

@@ -0,0 +1,111 @@
declare const _default: {
bf: {
ADD: typeof import("./bloom/ADD");
add: typeof import("./bloom/ADD");
CARD: typeof import("./bloom/CARD");
card: typeof import("./bloom/CARD");
EXISTS: typeof import("./bloom/EXISTS");
exists: typeof import("./bloom/EXISTS");
INFO: typeof import("./bloom/INFO");
info: typeof import("./bloom/INFO");
INSERT: typeof import("./bloom/INSERT");
insert: typeof import("./bloom/INSERT");
LOADCHUNK: typeof import("./bloom/LOADCHUNK");
loadChunk: typeof import("./bloom/LOADCHUNK");
MADD: typeof import("./bloom/MADD");
mAdd: typeof import("./bloom/MADD");
MEXISTS: typeof import("./bloom/MEXISTS");
mExists: typeof import("./bloom/MEXISTS");
RESERVE: typeof import("./bloom/RESERVE");
reserve: typeof import("./bloom/RESERVE");
SCANDUMP: typeof import("./bloom/SCANDUMP");
scanDump: typeof import("./bloom/SCANDUMP");
};
cms: {
INCRBY: typeof import("./count-min-sketch/INCRBY");
incrBy: typeof import("./count-min-sketch/INCRBY");
INFO: typeof import("./count-min-sketch/INFO");
info: typeof import("./count-min-sketch/INFO");
INITBYDIM: typeof import("./count-min-sketch/INITBYDIM");
initByDim: typeof import("./count-min-sketch/INITBYDIM");
INITBYPROB: typeof import("./count-min-sketch/INITBYPROB");
initByProb: typeof import("./count-min-sketch/INITBYPROB");
MERGE: typeof import("./count-min-sketch/MERGE");
merge: typeof import("./count-min-sketch/MERGE");
QUERY: typeof import("./count-min-sketch/QUERY");
query: typeof import("./count-min-sketch/QUERY");
};
cf: {
ADD: typeof import("./cuckoo/ADD");
add: typeof import("./cuckoo/ADD");
ADDNX: typeof import("./cuckoo/ADDNX");
addNX: typeof import("./cuckoo/ADDNX");
COUNT: typeof import("./cuckoo/COUNT");
count: typeof import("./cuckoo/COUNT");
DEL: typeof import("./cuckoo/DEL");
del: typeof import("./cuckoo/DEL");
EXISTS: typeof import("./cuckoo/EXISTS");
exists: typeof import("./cuckoo/EXISTS");
INFO: typeof import("./cuckoo/INFO");
info: typeof import("./cuckoo/INFO");
INSERT: typeof import("./cuckoo/INSERT");
insert: typeof import("./cuckoo/INSERT");
INSERTNX: typeof import("./cuckoo/INSERTNX");
insertNX: typeof import("./cuckoo/INSERTNX");
LOADCHUNK: typeof import("./cuckoo/LOADCHUNK");
loadChunk: typeof import("./cuckoo/LOADCHUNK");
RESERVE: typeof import("./cuckoo/RESERVE");
reserve: typeof import("./cuckoo/RESERVE");
SCANDUMP: typeof import("./cuckoo/SCANDUMP");
scanDump: typeof import("./cuckoo/SCANDUMP");
};
tDigest: {
ADD: typeof import("./t-digest/ADD");
add: typeof import("./t-digest/ADD");
BYRANK: typeof import("./t-digest/BYRANK");
byRank: typeof import("./t-digest/BYRANK");
BYREVRANK: typeof import("./t-digest/BYREVRANK");
byRevRank: typeof import("./t-digest/BYREVRANK");
CDF: typeof import("./t-digest/CDF");
cdf: typeof import("./t-digest/CDF");
CREATE: typeof import("./t-digest/CREATE");
create: typeof import("./t-digest/CREATE");
INFO: typeof import("./t-digest/INFO");
info: typeof import("./t-digest/INFO");
MAX: typeof import("./t-digest/MAX");
max: typeof import("./t-digest/MAX");
MERGE: typeof import("./t-digest/MERGE");
merge: typeof import("./t-digest/MERGE");
MIN: typeof import("./t-digest/MIN");
min: typeof import("./t-digest/MIN");
QUANTILE: typeof import("./t-digest/QUANTILE");
quantile: typeof import("./t-digest/QUANTILE");
RANK: typeof import("./t-digest/RANK");
rank: typeof import("./t-digest/RANK");
RESET: typeof import("./t-digest/RESET");
reset: typeof import("./t-digest/RESET");
REVRANK: typeof import("./t-digest/REVRANK");
revRank: typeof import("./t-digest/REVRANK");
TRIMMED_MEAN: typeof import("./t-digest/TRIMMED_MEAN");
trimmedMean: typeof import("./t-digest/TRIMMED_MEAN");
};
topK: {
ADD: typeof import("./top-k/ADD");
add: typeof import("./top-k/ADD");
COUNT: typeof import("./top-k/COUNT");
count: typeof import("./top-k/COUNT");
INCRBY: typeof import("./top-k/INCRBY");
incrBy: typeof import("./top-k/INCRBY");
INFO: typeof import("./top-k/INFO");
info: typeof import("./top-k/INFO");
LIST_WITHCOUNT: typeof import("./top-k/LIST_WITHCOUNT");
listWithCount: typeof import("./top-k/LIST_WITHCOUNT");
LIST: typeof import("./top-k/LIST");
list: typeof import("./top-k/LIST");
QUERY: typeof import("./top-k/QUERY");
query: typeof import("./top-k/QUERY");
RESERVE: typeof import("./top-k/RESERVE");
reserve: typeof import("./top-k/RESERVE");
};
};
export default _default;

View File

@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const bloom_1 = require("./bloom");
const count_min_sketch_1 = require("./count-min-sketch");
const cuckoo_1 = require("./cuckoo");
const t_digest_1 = require("./t-digest");
const top_k_1 = require("./top-k");
exports.default = {
bf: bloom_1.default,
cms: count_min_sketch_1.default,
cf: cuckoo_1.default,
tDigest: t_digest_1.default,
topK: top_k_1.default
};

View File

@@ -0,0 +1,4 @@
import { RedisCommandArgument, RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
export declare function transformArguments(key: RedisCommandArgument, values: Array<number>): RedisCommandArguments;
export declare function transformReply(): 'OK';

View File

@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformArguments = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
function transformArguments(key, values) {
const args = ['TDIGEST.ADD', key];
for (const item of values) {
args.push(item.toString());
}
return args;
}
exports.transformArguments = transformArguments;

View File

@@ -0,0 +1,5 @@
import { RedisCommandArgument, RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: RedisCommandArgument, ranks: Array<number>): RedisCommandArguments;
export { transformDoublesReply as transformReply } from '.';

View File

@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, ranks) {
const args = ['TDIGEST.BYRANK', key];
for (const rank of ranks) {
args.push(rank.toString());
}
return args;
}
exports.transformArguments = transformArguments;
var _1 = require(".");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return _1.transformDoublesReply; } });

View File

@@ -0,0 +1,5 @@
import { RedisCommandArgument, RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: RedisCommandArgument, ranks: Array<number>): RedisCommandArguments;
export { transformDoublesReply as transformReply } from '.';

View File

@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, ranks) {
const args = ['TDIGEST.BYREVRANK', key];
for (const rank of ranks) {
args.push(rank.toString());
}
return args;
}
exports.transformArguments = transformArguments;
var _1 = require(".");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return _1.transformDoublesReply; } });

View File

@@ -0,0 +1,5 @@
import { RedisCommandArgument, RedisCommandArguments } from '@redis/client/dist/lib/commands';
export declare const FIRST_KEY_INDEX = 1;
export declare const IS_READ_ONLY = true;
export declare function transformArguments(key: RedisCommandArgument, values: Array<number>): RedisCommandArguments;
export { transformDoublesReply as transformReply } from '.';

View File

@@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.transformReply = exports.transformArguments = exports.IS_READ_ONLY = exports.FIRST_KEY_INDEX = void 0;
exports.FIRST_KEY_INDEX = 1;
exports.IS_READ_ONLY = true;
function transformArguments(key, values) {
const args = ['TDIGEST.CDF', key];
for (const item of values) {
args.push(item.toString());
}
return args;
}
exports.transformArguments = transformArguments;
var _1 = require(".");
Object.defineProperty(exports, "transformReply", { enumerable: true, get: function () { return _1.transformDoublesReply; } });

Some files were not shown because too many files have changed in this diff Show More