mirror of
https://github.com/619dev/PaperPhone.git
synced 2026-05-06 22:12:41 +08:00
init
This commit is contained in:
168
README.md
Normal file
168
README.md
Normal 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
48
client/index.html
Normal 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
14
client/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
BIN
client/public/icons/icon-192.png
Normal file
BIN
client/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
client/public/icons/icon-512.png
Normal file
BIN
client/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
79
client/src/api.js
Normal file
79
client/src/api.js
Normal 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
201
client/src/app.js
Normal 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>`;
|
||||
}
|
||||
47
client/src/crypto/keystore.js
Normal file
47
client/src/crypto/keystore.js
Normal 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);
|
||||
});
|
||||
}
|
||||
281
client/src/crypto/ratchet.js
Normal file
281
client/src/crypto/ratchet.js
Normal 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
348
client/src/pages/chat.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
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
66
client/src/pages/chats.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
129
client/src/pages/contacts.js
Normal file
129
client/src/pages/contacts.js
Normal 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();
|
||||
}
|
||||
49
client/src/pages/discover.js
Normal file
49
client/src/pages/discover.js
Normal 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
106
client/src/pages/login.js
Normal 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
125
client/src/pages/profile.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
61
client/src/socket.js
Normal file
61
client/src/socket.js
Normal 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
617
client/src/style.css
Normal 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
64
client/sw.js
Normal 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
18
server/.env
Normal 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
27
server/.env.example
Normal 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
87
server/db/schema.sql
Normal 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
1
server/node_modules/.bin/fxparser
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../fast-xml-parser/src/cli/cli.js
|
||||
1
server/node_modules/.bin/mime
generated
vendored
Symbolic link
1
server/node_modules/.bin/mime
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../mime/cli.js
|
||||
1
server/node_modules/.bin/mkdirp
generated
vendored
Symbolic link
1
server/node_modules/.bin/mkdirp
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../mkdirp/bin/cmd.js
|
||||
1
server/node_modules/.bin/nodemon
generated
vendored
Symbolic link
1
server/node_modules/.bin/nodemon
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../nodemon/bin/nodemon.js
|
||||
1
server/node_modules/.bin/nodetouch
generated
vendored
Symbolic link
1
server/node_modules/.bin/nodetouch
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../touch/bin/nodetouch.js
|
||||
1
server/node_modules/.bin/semver
generated
vendored
Symbolic link
1
server/node_modules/.bin/semver
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../semver/bin/semver.js
|
||||
1
server/node_modules/.bin/uuid
generated
vendored
Symbolic link
1
server/node_modules/.bin/uuid
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../uuid/dist/bin/uuid
|
||||
2022
server/node_modules/.package-lock.json
generated
vendored
Normal file
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
14
server/node_modules/@redis/bloom/README.md
generated
vendored
Normal 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.
|
||||
3
server/node_modules/@redis/bloom/dist/commands/bloom/ADD.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/bloom/ADD.d.ts
generated
vendored
Normal 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';
|
||||
10
server/node_modules/@redis/bloom/dist/commands/bloom/ADD.js
generated
vendored
Normal file
10
server/node_modules/@redis/bloom/dist/commands/bloom/ADD.js
generated
vendored
Normal 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; } });
|
||||
4
server/node_modules/@redis/bloom/dist/commands/bloom/CARD.d.ts
generated
vendored
Normal file
4
server/node_modules/@redis/bloom/dist/commands/bloom/CARD.d.ts
generated
vendored
Normal 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;
|
||||
9
server/node_modules/@redis/bloom/dist/commands/bloom/CARD.js
generated
vendored
Normal file
9
server/node_modules/@redis/bloom/dist/commands/bloom/CARD.js
generated
vendored
Normal 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;
|
||||
4
server/node_modules/@redis/bloom/dist/commands/bloom/EXISTS.d.ts
generated
vendored
Normal file
4
server/node_modules/@redis/bloom/dist/commands/bloom/EXISTS.d.ts
generated
vendored
Normal 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';
|
||||
11
server/node_modules/@redis/bloom/dist/commands/bloom/EXISTS.js
generated
vendored
Normal file
11
server/node_modules/@redis/bloom/dist/commands/bloom/EXISTS.js
generated
vendored
Normal 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; } });
|
||||
23
server/node_modules/@redis/bloom/dist/commands/bloom/INFO.d.ts
generated
vendored
Normal file
23
server/node_modules/@redis/bloom/dist/commands/bloom/INFO.d.ts
generated
vendored
Normal 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;
|
||||
19
server/node_modules/@redis/bloom/dist/commands/bloom/INFO.js
generated
vendored
Normal file
19
server/node_modules/@redis/bloom/dist/commands/bloom/INFO.js
generated
vendored
Normal 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;
|
||||
11
server/node_modules/@redis/bloom/dist/commands/bloom/INSERT.d.ts
generated
vendored
Normal file
11
server/node_modules/@redis/bloom/dist/commands/bloom/INSERT.d.ts
generated
vendored
Normal 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';
|
||||
28
server/node_modules/@redis/bloom/dist/commands/bloom/INSERT.js
generated
vendored
Normal file
28
server/node_modules/@redis/bloom/dist/commands/bloom/INSERT.js
generated
vendored
Normal 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; } });
|
||||
4
server/node_modules/@redis/bloom/dist/commands/bloom/LOADCHUNK.d.ts
generated
vendored
Normal file
4
server/node_modules/@redis/bloom/dist/commands/bloom/LOADCHUNK.d.ts
generated
vendored
Normal 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';
|
||||
8
server/node_modules/@redis/bloom/dist/commands/bloom/LOADCHUNK.js
generated
vendored
Normal file
8
server/node_modules/@redis/bloom/dist/commands/bloom/LOADCHUNK.js
generated
vendored
Normal 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;
|
||||
3
server/node_modules/@redis/bloom/dist/commands/bloom/MADD.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/bloom/MADD.d.ts
generated
vendored
Normal 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';
|
||||
10
server/node_modules/@redis/bloom/dist/commands/bloom/MADD.js
generated
vendored
Normal file
10
server/node_modules/@redis/bloom/dist/commands/bloom/MADD.js
generated
vendored
Normal 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; } });
|
||||
4
server/node_modules/@redis/bloom/dist/commands/bloom/MEXISTS.d.ts
generated
vendored
Normal file
4
server/node_modules/@redis/bloom/dist/commands/bloom/MEXISTS.d.ts
generated
vendored
Normal 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';
|
||||
11
server/node_modules/@redis/bloom/dist/commands/bloom/MEXISTS.js
generated
vendored
Normal file
11
server/node_modules/@redis/bloom/dist/commands/bloom/MEXISTS.js
generated
vendored
Normal 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; } });
|
||||
8
server/node_modules/@redis/bloom/dist/commands/bloom/RESERVE.d.ts
generated
vendored
Normal file
8
server/node_modules/@redis/bloom/dist/commands/bloom/RESERVE.d.ts
generated
vendored
Normal 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 {};
|
||||
15
server/node_modules/@redis/bloom/dist/commands/bloom/RESERVE.js
generated
vendored
Normal file
15
server/node_modules/@redis/bloom/dist/commands/bloom/RESERVE.js
generated
vendored
Normal 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;
|
||||
13
server/node_modules/@redis/bloom/dist/commands/bloom/SCANDUMP.d.ts
generated
vendored
Normal file
13
server/node_modules/@redis/bloom/dist/commands/bloom/SCANDUMP.d.ts
generated
vendored
Normal 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 {};
|
||||
16
server/node_modules/@redis/bloom/dist/commands/bloom/SCANDUMP.js
generated
vendored
Normal file
16
server/node_modules/@redis/bloom/dist/commands/bloom/SCANDUMP.js
generated
vendored
Normal 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;
|
||||
33
server/node_modules/@redis/bloom/dist/commands/bloom/index.d.ts
generated
vendored
Normal file
33
server/node_modules/@redis/bloom/dist/commands/bloom/index.d.ts
generated
vendored
Normal 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;
|
||||
34
server/node_modules/@redis/bloom/dist/commands/bloom/index.js
generated
vendored
Normal file
34
server/node_modules/@redis/bloom/dist/commands/bloom/index.js
generated
vendored
Normal 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
|
||||
};
|
||||
8
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INCRBY.d.ts
generated
vendored
Normal file
8
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INCRBY.d.ts
generated
vendored
Normal 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 {};
|
||||
20
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INCRBY.js
generated
vendored
Normal file
20
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INCRBY.js
generated
vendored
Normal 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());
|
||||
}
|
||||
17
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INFO.d.ts
generated
vendored
Normal file
17
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INFO.d.ts
generated
vendored
Normal 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;
|
||||
17
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INFO.js
generated
vendored
Normal file
17
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INFO.js
generated
vendored
Normal 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;
|
||||
3
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYDIM.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYDIM.d.ts
generated
vendored
Normal 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';
|
||||
8
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYDIM.js
generated
vendored
Normal file
8
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYDIM.js
generated
vendored
Normal 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;
|
||||
3
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYPROB.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYPROB.d.ts
generated
vendored
Normal 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';
|
||||
8
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYPROB.js
generated
vendored
Normal file
8
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/INITBYPROB.js
generated
vendored
Normal 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;
|
||||
9
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/MERGE.d.ts
generated
vendored
Normal file
9
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/MERGE.d.ts
generated
vendored
Normal 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 {};
|
||||
28
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/MERGE.js
generated
vendored
Normal file
28
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/MERGE.js
generated
vendored
Normal 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';
|
||||
}
|
||||
5
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/QUERY.d.ts
generated
vendored
Normal file
5
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/QUERY.d.ts
generated
vendored
Normal 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>;
|
||||
10
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/QUERY.js
generated
vendored
Normal file
10
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/QUERY.js
generated
vendored
Normal 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;
|
||||
21
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/index.d.ts
generated
vendored
Normal file
21
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/index.d.ts
generated
vendored
Normal 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;
|
||||
22
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/index.js
generated
vendored
Normal file
22
server/node_modules/@redis/bloom/dist/commands/count-min-sketch/index.js
generated
vendored
Normal 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
|
||||
};
|
||||
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADD.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADD.d.ts
generated
vendored
Normal 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';
|
||||
10
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADD.js
generated
vendored
Normal file
10
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADD.js
generated
vendored
Normal 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; } });
|
||||
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADDNX.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADDNX.d.ts
generated
vendored
Normal 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';
|
||||
10
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADDNX.js
generated
vendored
Normal file
10
server/node_modules/@redis/bloom/dist/commands/cuckoo/ADDNX.js
generated
vendored
Normal 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; } });
|
||||
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/COUNT.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/COUNT.d.ts
generated
vendored
Normal 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;
|
||||
8
server/node_modules/@redis/bloom/dist/commands/cuckoo/COUNT.js
generated
vendored
Normal file
8
server/node_modules/@redis/bloom/dist/commands/cuckoo/COUNT.js
generated
vendored
Normal 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;
|
||||
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/DEL.d.ts
generated
vendored
Normal file
3
server/node_modules/@redis/bloom/dist/commands/cuckoo/DEL.d.ts
generated
vendored
Normal 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';
|
||||
10
server/node_modules/@redis/bloom/dist/commands/cuckoo/DEL.js
generated
vendored
Normal file
10
server/node_modules/@redis/bloom/dist/commands/cuckoo/DEL.js
generated
vendored
Normal 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; } });
|
||||
4
server/node_modules/@redis/bloom/dist/commands/cuckoo/EXISTS.d.ts
generated
vendored
Normal file
4
server/node_modules/@redis/bloom/dist/commands/cuckoo/EXISTS.d.ts
generated
vendored
Normal 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';
|
||||
11
server/node_modules/@redis/bloom/dist/commands/cuckoo/EXISTS.js
generated
vendored
Normal file
11
server/node_modules/@redis/bloom/dist/commands/cuckoo/EXISTS.js
generated
vendored
Normal 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; } });
|
||||
32
server/node_modules/@redis/bloom/dist/commands/cuckoo/INFO.d.ts
generated
vendored
Normal file
32
server/node_modules/@redis/bloom/dist/commands/cuckoo/INFO.d.ts
generated
vendored
Normal 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;
|
||||
22
server/node_modules/@redis/bloom/dist/commands/cuckoo/INFO.js
generated
vendored
Normal file
22
server/node_modules/@redis/bloom/dist/commands/cuckoo/INFO.js
generated
vendored
Normal 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;
|
||||
5
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERT.d.ts
generated
vendored
Normal file
5
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERT.d.ts
generated
vendored
Normal 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';
|
||||
11
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERT.js
generated
vendored
Normal file
11
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERT.js
generated
vendored
Normal 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; } });
|
||||
5
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERTNX.d.ts
generated
vendored
Normal file
5
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERTNX.d.ts
generated
vendored
Normal 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';
|
||||
11
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERTNX.js
generated
vendored
Normal file
11
server/node_modules/@redis/bloom/dist/commands/cuckoo/INSERTNX.js
generated
vendored
Normal 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; } });
|
||||
4
server/node_modules/@redis/bloom/dist/commands/cuckoo/LOADCHUNK.d.ts
generated
vendored
Normal file
4
server/node_modules/@redis/bloom/dist/commands/cuckoo/LOADCHUNK.d.ts
generated
vendored
Normal 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';
|
||||
8
server/node_modules/@redis/bloom/dist/commands/cuckoo/LOADCHUNK.js
generated
vendored
Normal file
8
server/node_modules/@redis/bloom/dist/commands/cuckoo/LOADCHUNK.js
generated
vendored
Normal 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;
|
||||
9
server/node_modules/@redis/bloom/dist/commands/cuckoo/RESERVE.d.ts
generated
vendored
Normal file
9
server/node_modules/@redis/bloom/dist/commands/cuckoo/RESERVE.d.ts
generated
vendored
Normal 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 {};
|
||||
18
server/node_modules/@redis/bloom/dist/commands/cuckoo/RESERVE.js
generated
vendored
Normal file
18
server/node_modules/@redis/bloom/dist/commands/cuckoo/RESERVE.js
generated
vendored
Normal 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;
|
||||
12
server/node_modules/@redis/bloom/dist/commands/cuckoo/SCANDUMP.d.ts
generated
vendored
Normal file
12
server/node_modules/@redis/bloom/dist/commands/cuckoo/SCANDUMP.d.ts
generated
vendored
Normal 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 {};
|
||||
15
server/node_modules/@redis/bloom/dist/commands/cuckoo/SCANDUMP.js
generated
vendored
Normal file
15
server/node_modules/@redis/bloom/dist/commands/cuckoo/SCANDUMP.js
generated
vendored
Normal 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;
|
||||
42
server/node_modules/@redis/bloom/dist/commands/cuckoo/index.d.ts
generated
vendored
Normal file
42
server/node_modules/@redis/bloom/dist/commands/cuckoo/index.d.ts
generated
vendored
Normal 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;
|
||||
51
server/node_modules/@redis/bloom/dist/commands/cuckoo/index.js
generated
vendored
Normal file
51
server/node_modules/@redis/bloom/dist/commands/cuckoo/index.js
generated
vendored
Normal 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;
|
||||
111
server/node_modules/@redis/bloom/dist/commands/index.d.ts
generated
vendored
Normal file
111
server/node_modules/@redis/bloom/dist/commands/index.d.ts
generated
vendored
Normal 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;
|
||||
14
server/node_modules/@redis/bloom/dist/commands/index.js
generated
vendored
Normal file
14
server/node_modules/@redis/bloom/dist/commands/index.js
generated
vendored
Normal 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
|
||||
};
|
||||
4
server/node_modules/@redis/bloom/dist/commands/t-digest/ADD.d.ts
generated
vendored
Normal file
4
server/node_modules/@redis/bloom/dist/commands/t-digest/ADD.d.ts
generated
vendored
Normal 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';
|
||||
12
server/node_modules/@redis/bloom/dist/commands/t-digest/ADD.js
generated
vendored
Normal file
12
server/node_modules/@redis/bloom/dist/commands/t-digest/ADD.js
generated
vendored
Normal 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;
|
||||
5
server/node_modules/@redis/bloom/dist/commands/t-digest/BYRANK.d.ts
generated
vendored
Normal file
5
server/node_modules/@redis/bloom/dist/commands/t-digest/BYRANK.d.ts
generated
vendored
Normal 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 '.';
|
||||
15
server/node_modules/@redis/bloom/dist/commands/t-digest/BYRANK.js
generated
vendored
Normal file
15
server/node_modules/@redis/bloom/dist/commands/t-digest/BYRANK.js
generated
vendored
Normal 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; } });
|
||||
5
server/node_modules/@redis/bloom/dist/commands/t-digest/BYREVRANK.d.ts
generated
vendored
Normal file
5
server/node_modules/@redis/bloom/dist/commands/t-digest/BYREVRANK.d.ts
generated
vendored
Normal 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 '.';
|
||||
15
server/node_modules/@redis/bloom/dist/commands/t-digest/BYREVRANK.js
generated
vendored
Normal file
15
server/node_modules/@redis/bloom/dist/commands/t-digest/BYREVRANK.js
generated
vendored
Normal 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; } });
|
||||
5
server/node_modules/@redis/bloom/dist/commands/t-digest/CDF.d.ts
generated
vendored
Normal file
5
server/node_modules/@redis/bloom/dist/commands/t-digest/CDF.d.ts
generated
vendored
Normal 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 '.';
|
||||
15
server/node_modules/@redis/bloom/dist/commands/t-digest/CDF.js
generated
vendored
Normal file
15
server/node_modules/@redis/bloom/dist/commands/t-digest/CDF.js
generated
vendored
Normal 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
Reference in New Issue
Block a user