Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27375c1a4d | |||
| 17a43ec181 | |||
| 723a1ea086 | |||
| 10f236dc8d | |||
| 89a38936b6 | |||
| 18f6703c6b | |||
| 415adfe638 | |||
| 0c4397bf46 | |||
| 2840da2f34 | |||
| 2f5ed92188 | |||
| 3d222fd316 | |||
| b636953770 | |||
| 9d9b8036c4 | |||
| 30635864da | |||
| 47beed3e7f | |||
| b2aa493e2d | |||
| 5ca89d71ad | |||
| 0d308515a7 | |||
| 0621fb27db | |||
| 99f264fcd0 | |||
| ad6fc85df1 | |||
| ffa9f84c4a | |||
| 1e22481a00 | |||
| f0c4364dcd | |||
| 8ef43029d5 | |||
| 975f6e74ad | |||
| dec59a12ec | |||
| 8590eda2c2 | |||
| 1f4522eeb2 | |||
| 663082efe1 | |||
| 2bd09835b1 | |||
| 7809373f88 | |||
| 85baa97804 | |||
| 246eff196e | |||
| a498cc4799 | |||
| 200fc65617 | |||
| 2ee6961634 | |||
| cfcd60145a | |||
| 67b46d0b30 | |||
| 3f075c4a97 | |||
| 55f118be9a | |||
| 95331cb8e6 | |||
| d0ba2eb9c4 | |||
| 79089bed7e | |||
| 4dcdf0c3a0 | |||
| 327de90f52 | |||
| b5404cea72 | |||
| 33f2f041ac | |||
| 8627544946 | |||
| 158433bb0b | |||
| 6f8f4f65bb | |||
| 61e7c1db50 | |||
| 526e1b49c1 | |||
| 0747898f3c | |||
| 8ff2302c14 | |||
| 5ca911d1e1 | |||
| 230a06b3fb | |||
| 99c927f5c7 | |||
| 3f18002cf0 | |||
| e385389e1d | |||
| 81c2b204f3 | |||
| ec6a18dfc0 | |||
| a82ca50ee9 | |||
| 0bcd2c0f97 | |||
| 5af2e8db37 | |||
| 1aa738425f | |||
| 0562e8a3a8 | |||
| c0317211e7 | |||
| 7f33064109 | |||
| ad4a951525 | |||
| 4437c70257 | |||
| b38ef84bca | |||
| 9b6e6559fe | |||
| d2153d7630 |
@@ -0,0 +1,56 @@
|
||||
# Git相关
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# 环境变量文件
|
||||
.env*
|
||||
!.env.docker.example
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 依赖目录
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
|
||||
# 构建目录
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 缓存目录
|
||||
.npm
|
||||
.pnpm-store
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# 编辑器配置
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 操作系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker相关
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# 文档文件
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# 测试文件
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# 其他
|
||||
.eslintcache
|
||||
tsconfig.tsbuildinfo
|
||||
+9
-1
@@ -16,6 +16,8 @@ coverage/
|
||||
.next/
|
||||
# Ignore out/ directories in all subdirectories
|
||||
out/
|
||||
out.zip
|
||||
deploy.config*
|
||||
|
||||
# production
|
||||
# Ignore build/ directories in all subdirectories
|
||||
@@ -60,6 +62,12 @@ next-env.d.ts
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Generated docker assets
|
||||
docker/ssl/
|
||||
docker/nginx/
|
||||
docker/coturn/
|
||||
logs/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
.tmp/
|
||||
.tmp/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# AGENTS — PrivyDrop Repository Rules(简版)
|
||||
|
||||
最重要的原则
|
||||
|
||||
- 用中文沟通:与项目负责人沟通一律使用中文(简体)。代码注释、命名、提交信息、PR 标题与描述统一使用英文。
|
||||
- 方案与代码遵循最佳实践:优先选择与现有技术栈一致、被验证过的实现;小步迭代、易回滚。
|
||||
- 计划先行:任何实现前须提交并获批变更计划(目标、范围/文件、方案、风险、验收、回滚、需更新文档、验证方式)。模板见 docs/ai-playbook/collab-rules.zh-CN.md。
|
||||
- 单一主题:每次改动只解决一个明确目标,避免“顺手修复”无关问题,保持最小可回滚。
|
||||
- 隐私与架构红线:后端仅做信令与房间管理;严禁任何形式的文件数据中转、存储或上报至第三方。
|
||||
- 传输护栏:保持既定分片/背压/重试等关键参数与机制;任何破坏性或参数层变更需先获批。
|
||||
- 依赖与基建:未经批准不得新增依赖/组件库/基础设施或进行大规模重构。
|
||||
- 文档同步:涉及流程、接口或入口文件路径的改动,必须在同一 PR 内同步更新 docs/ai-playbook/flows.zh-CN.md 与 docs/ai-playbook/code-map.zh-CN.md。
|
||||
- 验证要求:前端需构建通过(next build);列出关键手测用例与回归点。
|
||||
|
||||
优先级与冲突
|
||||
|
||||
- 显式用户指令优先于本文件;如有冲突需在计划中说明并征得同意。
|
||||
- 更多细则、示例与校验清单以 docs/ai-playbook/collab-rules.zh-CN.md 为准(本文件仅保留最原则条款)。
|
||||
@@ -20,6 +20,7 @@ We believe everyone should have control over their own data. PrivyDrop was creat
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
- 🔄 **Unlimited File Transfer** - Support files of any size through Chrome's direct-to-disk streaming (Need to set the save directory)
|
||||
- 🔒 **End-to-End Encryption**: Leverages P2P direct connections via WebRTC. All files and text are transferred directly between browsers without passing through any central server.
|
||||
- 📂 **File & Folder Transfer**: Supports transferring multiple files and entire folders.
|
||||
- ⏸️ **Resume Transfer**: Resume file transfer from the point of interruption. Simply set the save directory to enable this feature, ensuring your large files are delivered safely even with unstable networks. If interrupted, you currently need to refresh both the sender and receiver web pages to restart the transfer.
|
||||
@@ -28,6 +29,7 @@ We believe everyone should have control over their own data. PrivyDrop was creat
|
||||
- 🔗 **Convenient Sharing**: Easily share a room and establish a connection via a link or QR code.
|
||||
- 📱 **Multi-Device Support**: Responsive design supports both desktop and mobile browsers.
|
||||
- 🌐 **Internationalization**: Supports multiple languages, including English and Chinese.
|
||||
- 🧭 **In-App Navigation Persistence**: For in-app navigation within the same browser tab (Next.js App Router page switches), ongoing transfers are not interrupted, and selected-to-send items and already-displayed received content are preserved. Powered by a singleton app state (Zustand Store) and a singleton connection service (webrtcService).
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
@@ -35,17 +37,51 @@ We believe everyone should have control over their own data. PrivyDrop was creat
|
||||
- **Backend**: Node.js, Express.js, TypeScript
|
||||
- **Real-time Communication**: WebRTC, Socket.IO
|
||||
- **Data Storage**: Redis
|
||||
- **Deployment**: PM2, Nginx, Docker [WIP]
|
||||
- **Deployment**: PM2, Nginx, Docker
|
||||
|
||||
## 🐳 Docker One-Click Deployment (Recommended)
|
||||
|
||||
Deploy in minutes with zero manual configuration. Supports private/public networks and auto HTTPS (Let’s Encrypt).
|
||||
|
||||
```bash
|
||||
# Private LAN (no domain/public IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# Private LAN + TURN (for complex NAT/LAN)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# Public IP without domain (with TURN; recommended with Nginx for same-origin)
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
|
||||
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
See [Docker Deployment Guide](./docs/DEPLOYMENT_docker.md) (Modes Overview, LAN TLS limitations, Let’s Encrypt auto-issue/renew)
|
||||
|
||||
Heads-up (LAN TLS, self-signed)
|
||||
|
||||
- Import the CA certificate into your browser (or system trust store) on first use: `docker/ssl/ca-cert.pem`. Otherwise the browser shows “certificate not valid/untrusted”.
|
||||
- Access endpoints (by default):
|
||||
- Nginx: `http://localhost`
|
||||
- HTTPS: `https://localhost:8443`, `https://<your LAN IP>:8443`
|
||||
- Frontend dev ports (optional): `http://localhost:3002`, `http://<your LAN IP>:3002`
|
||||
- When `--with-nginx` is enabled, the frontend and API are same-origin (`/api`, `/socket.io`) for stability; direct ports `:3002/:3001` are for debugging only and may cause CORS or 404.
|
||||
- With `--enable-web-https` and the CA trusted, same-origin HTTPS (8443) avoids CORS; common dev origins (`localhost`, `:3002`) are allowed by default.
|
||||
|
||||
## 🚀 Quick Start (Full-Stack Local Development)
|
||||
|
||||
Before you begin, ensure your development environment has [Node.js](https://nodejs.org/) (v18+), [npm](https://www.npmjs.com/), and a running [Redis](https://redis.io/) instance installed.
|
||||
|
||||
1. **Clone the Project**
|
||||
1. **Clone the Project & install redis**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd PrivyDrop
|
||||
sudo apt-get install -y redis-server
|
||||
```
|
||||
|
||||
2. **Configure and Start the Backend Service**
|
||||
@@ -66,7 +102,7 @@ Before you begin, ensure your development environment has [Node.js](https://node
|
||||
cd frontend
|
||||
pnpm install
|
||||
|
||||
# Copy the development environment file, then modify .env.development as needed
|
||||
# Copy the development environment file, then modify .env.development as needed, Remove optional items
|
||||
cp .env_development_example .env.development
|
||||
|
||||
pnpm dev # Starts by default at http://localhost:3002
|
||||
@@ -89,6 +125,7 @@ We provide detailed documentation to help you dive deeper into the project's des
|
||||
- [**Frontend Architecture Deep Dive**](./docs/FRONTEND_ARCHITECTURE.md): Explore the frontend's modern, layered architecture, state management with Zustand, and the decoupled service-based approach to WebRTC.
|
||||
- [**Backend Architecture Deep Dive**](./docs/BACKEND_ARCHITECTURE.md): Dive into the backend's code structure, signaling flow, and Redis design.
|
||||
- [**Deployment Guide**](./docs/DEPLOYMENT.md): Learn how to deploy the complete PrivyDrop application in a production environment.
|
||||
- [AI Playbook (zh-CN)](./docs/ai-playbook/index.zh-CN.md) · [Collaboration Rules (zh-CN)](./docs/ai-playbook/collab-rules.zh-CN.md)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
+53
-5
@@ -20,6 +20,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
- 🔄 **无限制文件传输** - 支持任意大小文件传输,通过 Chrome 的流式保存到磁盘功能实现(需设置保存目录)
|
||||
- 🔒 **端到端加密**: 基于 WebRTC 的 P2P 直连技术,所有文件和文本在浏览器间直接传输,不经过任何中央服务器。
|
||||
- 📂 **文件与文件夹传输**: 支持多文件和整个文件夹的传输。
|
||||
- ⏸️ **断点续传**: 自动从中断处恢复文件传输。只需设置保存目录即可启用此功能,确保即使在网络不稳定的情况下,您的大文件也能安全送达。如果中断,目前需要同时刷新发送端和接收端网页,重新开始传输即可。
|
||||
@@ -28,6 +29,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
||||
- 🔗 **便捷分享**: 通过链接或二维码轻松分享房间,建立连接。
|
||||
- 📱 **多端支持**: 响应式设计,支持桌面和移动端浏览器。
|
||||
- 🌐 **国际化**: 支持中文、英文等多个语言。
|
||||
- 🧭 **站内导航不中断/状态保持**: 在同一标签页的站内跳转(Next.js App Router 页面切换)时,进行中的传输不会中断,已选择的待发送内容与接收页已展示的文本/文件清单也不会丢失。该能力依赖前端的单例状态(Zustand Store)与单例连接服务(webrtcService)。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
@@ -35,17 +37,62 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
||||
- **后端**: Node.js, Express.js, TypeScript
|
||||
- **实时通信**: WebRTC, Socket.IO
|
||||
- **数据存储**: Redis
|
||||
- **部署**: PM2, Nginx, Docker[暂未支持]
|
||||
- **部署**: PM2, Nginx, Docker
|
||||
|
||||
## 🚀 快速上手 (本地全栈开发)
|
||||
## 🚀 快速上手
|
||||
|
||||
### 🐳 Docker 一键部署 (推荐)
|
||||
|
||||
**零配置,一条命令完成部署!支持内网/公网/域名,自动签发/续期 HTTPS。**
|
||||
|
||||
```bash
|
||||
# 内网(无域名/无公网IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# 内网 + TURN(推荐用于复杂内网/NAT)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# 内网 HTTPS(自签,开发/受管环境,显式开启 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# 公网IP(无域名),含 TURN(推荐同源经 Nginx)
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
|
||||
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
完整说明见: docs/DEPLOYMENT_docker.zh-CN.md(模式一览、LAN TLS、自签限制、Let’s Encrypt 自动签发/续期)
|
||||
|
||||
提示(lan-tls 自签 HTTPS)
|
||||
|
||||
- 首次访问需导入 CA 证书:`docker/ssl/ca-cert.pem` 到浏览器(或系统信任),否则浏览器会提示“证书无效/不受信任”。
|
||||
- 访问方式(默认):
|
||||
- Nginx: `http://localhost`
|
||||
- HTTPS: `https://localhost:8443`、`https://<你的局域网IP>:8443`
|
||||
- 前端开发口(可选): `http://localhost:3002`、`http://<你的局域网IP>:3002`
|
||||
- 启用 `--with-nginx` 时,前端与 API 同源(/api、/socket.io)更稳定;直连 `:3002/:3001` 仅用于调试,可能导致 CORS 或 404。
|
||||
- 若启用了 `--enable-web-https` 并导入 CA,浏览器与 API 走同源 HTTPS(8443)可避免 CORS;我们已默认放开常见开发来源(`localhost`、`:3002` 等)。
|
||||
|
||||
**部署优势**:
|
||||
|
||||
- ✅ 部署时间: 60 分钟 → 5 分钟
|
||||
- ✅ 技术门槛: Linux 运维 → 会用 Docker 即可
|
||||
- ✅ 环境要求: 公网 IP → 内网即可使用
|
||||
- ✅ 成功率: 70% → 95%+
|
||||
|
||||
详见: [Docker 部署指南](./docs/DEPLOYMENT_docker.zh-CN.md)
|
||||
|
||||
### 💻 本地开发环境
|
||||
|
||||
在开始之前,请确保你的开发环境已安装 [Node.js](https://nodejs.org/) (v18+), [npm](https://www.npmjs.com/) 以及一个正在运行的 [Redis](https://redis.io/) 实例。
|
||||
|
||||
1. **克隆项目**
|
||||
1. **克隆项目 & 安装 redis**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd privydrop
|
||||
cd PrivyDrop
|
||||
sudo apt-get install -y redis-server
|
||||
```
|
||||
|
||||
2. **配置并启动后端服务**
|
||||
@@ -66,7 +113,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
||||
cd frontend
|
||||
pnpm install
|
||||
|
||||
# 复制开发环境变量文件,然后根据需要修改 .env.development
|
||||
# 复制开发环境变量文件,然后根据需要修改 .env.development,删除可选项
|
||||
cp .env_development_example .env.development
|
||||
|
||||
pnpm dev # 默认启动于 http://localhost:3002
|
||||
@@ -89,6 +136,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
||||
- [**前端架构详解**](./docs/FRONTEND_ARCHITECTURE.zh-CN.md): 深入探索前端的现代化分层架构、基于 Zustand 的状态管理,以及解耦的服务化 WebRTC 实现。
|
||||
- [**后端架构详解**](./docs/BACKEND_ARCHITECTURE.zh-CN.md): 深入探索后端的代码结构、信令流程和 Redis 设计。
|
||||
- [**部署指南**](./docs/DEPLOYMENT.zh-CN.md): 学习如何在生产环境部署完整的 PrivyDrop 应用。
|
||||
- [AI Playbook 索引](./docs/ai-playbook/index.zh-CN.md) · [协作规则](./docs/ai-playbook/collab-rules.zh-CN.md)
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
|
||||
+46
-42
@@ -1,63 +1,67 @@
|
||||
# PrivyDrop Project Roadmap
|
||||
# PrivyDrop Roadmap
|
||||
|
||||
Welcome to the official roadmap for PrivyDrop! This document outlines our vision for the future, detailing the planned features and improvements that will shape the project. Our goal is to build the most secure, private, and user-friendly P2P file sharing solution.
|
||||
## Overview
|
||||
|
||||
This roadmap is a living document. We welcome community feedback and contributions. If you have an idea or want to help build the future of PrivyDrop, please open an [Issue](https://github.com/david-bai00/PrivyDrop/issues) or a [Pull Request](https://github.com/david-bai00/PrivyDrop/pulls)!
|
||||
- Vision: keep file/text transfer lightweight, smooth, reliable, and easy to self‑host.
|
||||
- Current snapshot: resumable transfer, chunking + backpressure, Safari/Firefox support, Docker one‑click deploy.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed
|
||||
## Scope
|
||||
|
||||
- **Core Architecture Refactor (Q3 2025)**: Successfully refactored the entire frontend codebase to a modern, layered architecture.
|
||||
- Implemented a framework-agnostic **Service Layer** (`webrtcService`) to encapsulate all WebRTC and business logic.
|
||||
- Introduced **Zustand** for centralized, predictable state management (`fileTransferStore`).
|
||||
- Decoupled UI components from business logic, establishing a clear, unidirectional data flow.
|
||||
- **Resumable File Transfers (Q3 2025):** Implemented robust logic for resuming transfers from the point of interruption. This is enabled by setting a save directory, which allows the receiver to check for partially downloaded files and request only the missing chunks.
|
||||
- Scope: file/text transfer only (one‑to‑many), room‑based sessions.
|
||||
|
||||
---
|
||||
|
||||
## Short-Term Goals (Next 1-3 Months)
|
||||
## Near‑Term Roadmap (by priority, no dates)
|
||||
|
||||
This phase focuses on perfecting the current feature set and enhancing reliability to build an even stronger foundation.
|
||||
- P0 Code Optimization & Slimming
|
||||
|
||||
- **Enhanced Connection Stability:** The current implementation supports automatic reconnection for a short period (e.g., 15 minutes) in default 4-digit rooms. This will be extended to support custom-named rooms with a longer reconnection window (e.g., 1 hour).
|
||||
- **Detailed Transfer Error-Handling:** Provide users with clearer, more specific feedback when a transfer fails (e.g., "Peer disconnected," "Browser storage full," "Network interrupted").
|
||||
- Architecture convergence & clear boundaries: transport (send/receive), WebRTC wrapper, state, and UI separated; split oversized files; centralize shared types/constants.
|
||||
- Redundancy cleanup: remove dead code/unused exports; merge duplicate utilities and logic (keep a single authority for packet encode/decode).
|
||||
- Unified config & naming: chunk/batch/backpressure thresholds from a single source; unify naming; do not change behavior.
|
||||
- State management coherence: Zustand as the single source of truth; custom hooks only subscribe/dispatch intent, no business logic.
|
||||
- Async & error path simplification: unify Promise/event patterns and return values; centralize error types and boundaries.
|
||||
- Logging & debug (key runtime item): unified logger with levels (error/warn/info/debug) and toggle; default low‑noise in production; replace scattered console/postLog; consistent IDs by room/session/file.
|
||||
- Type & build health: gradually tighten TS, reduce any/implicit any; keep lint/format consistent.
|
||||
|
||||
- P0 Minimal Test Set
|
||||
|
||||
- Unit tests: chunk read/slice, embedded packet parse, sequenced disk writer handling of out‑of‑order/duplicate/tail chunks.
|
||||
- Lightweight integration: headless/fake data channel to verify send→receive→persist, covering backpressure wait and resume path.
|
||||
- Backend minimal tests: room and rate‑limit core contracts.
|
||||
|
||||
- P1 Error UX & Read‑only Network Check
|
||||
|
||||
- Clear, actionable errors with retry suggestions; visible send/receive states and failure summaries.
|
||||
- Read‑only panel: connection state, data channel state, send buffer, current/avg rate, recent errors. Display only; no complex probing.
|
||||
|
||||
- P1 Docs & Deployment Consistency
|
||||
- Aligned quickstart and Docker self‑hosting; FAQ and troubleshooting; consistent screenshots and terminology.
|
||||
- Frontend architecture docs synced (Zustand + custom hooks).
|
||||
|
||||
---
|
||||
|
||||
## Mid-Term Goals (Next 3-9 Months)
|
||||
## Definition of Done
|
||||
|
||||
This phase introduces powerful new features that expand PrivyDrop's use cases beyond one-to-one file transfers.
|
||||
- P0 Code Optimization & Slimming
|
||||
|
||||
- **[Major Feature] P2P Group Chat:** While multiple peers can already join a room, this feature will add a simple, host-based group chat. The room creator will act as a hub to relay encrypted text and files to all other participants, enabling basic group collaboration.
|
||||
- **Self-Destructing Messages & Files:** Allow users to send files or text messages that are automatically deleted from the recipient's view after being read or after a set time.
|
||||
- **Clipboard Synchronization:** Add a dedicated mode to sync the clipboard content (text and images) in real-time between connected devices.
|
||||
- **Official Docker Support:** Provide and maintain official `Dockerfile` and `docker-compose.yml` configurations for easy, one-command self-hosting of the entire stack.
|
||||
- Clear module boundaries; unified directory/naming; duplicates merged; dead code removed.
|
||||
- Single source for chunk/batch/backpressure config, with behavior unchanged.
|
||||
- Zustand as the only state source; components free of business side‑effects; custom hooks roles are clear.
|
||||
- Logger levels and toggle in place; production low‑noise; no stray debug output.
|
||||
- Build and lint pass; TypeScript warnings significantly reduced.
|
||||
|
||||
### Performance and deployment
|
||||
|
||||
- **Official Docker support:** Provide and maintain the official `Dockerfile` and `docker-compose.yml` configurations to achieve one-click deployment of the entire technology stack (frontend, backend, Redis, Coturn), which greatly facilitates self-hosted users.
|
||||
- **Package size optimization:** Regularly use `@next/bundle-analyzer` to analyze the frontend package size, optimize through code splitting and other means, and keep the application lightweight.
|
||||
|
||||
### User Experience (UX)
|
||||
|
||||
- **To be defined**
|
||||
- P0 Minimal Test Set
|
||||
- Core edge cases covered by unit tests; at least one minimal integration path completes send→receive→persist.
|
||||
|
||||
---
|
||||
|
||||
## Future & Community-Driven Ideas
|
||||
## Terminology
|
||||
|
||||
This section is for features that are not on the immediate roadmap but represent great opportunities for community contributions.
|
||||
|
||||
- **Comprehensive Testing:** The recent architectural refactor has made the codebase significantly more testable. We now plan to introduce a testing framework (like Vitest) to add unit tests for the core `webrtcService` and Zustand store, improving code quality and making community contributions safer. We welcome contributions in this area.
|
||||
- **Your Ideas Here:** Have a great idea for a feature, like screen sharing or P2P media streaming? Open an issue and let's discuss it! We believe the best ideas can come from the community.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Your contributions are vital to making this roadmap a reality!
|
||||
|
||||
1. **Pick an Issue:** Look for issues tagged with `help wanted` or `good first issue`.
|
||||
2. **Start a Discussion:** If you're interested in a roadmap item, start a discussion to share your ideas.
|
||||
3. **Submit a PR:** Fork the repo, create a feature branch, and submit a Pull Request.
|
||||
|
||||
Thank you for being part of the PrivyDrop community! Let's build the future of private sharing, together.
|
||||
- Sender/Receiver
|
||||
- Room
|
||||
- Chunk / Backpressure
|
||||
- Resume
|
||||
- DataChannel
|
||||
- Persist to disk (OPFS/disk write)
|
||||
|
||||
+47
-42
@@ -1,64 +1,69 @@
|
||||
# PrivyDrop 项目发展路线图
|
||||
# PrivyDrop 项目路线图
|
||||
|
||||
欢迎来到 PrivyDrop 的官方路线图!本文档将详细阐述我们对未来的愿景,以及计划中的功能和改进。我们的目标是构建最安全、最私密、最友好的 P2P 文件分享解决方案。
|
||||
## 简介
|
||||
|
||||
这份路线图是一份动态文档,我们真诚地欢迎社区的反馈和贡献。如果你有任何想法,或希望帮助我们共创 PrivyDrop 的未来,请随时开启一个 [Issue](https://github.com/david-bai00/PrivyDrop/issues) 或提交一个 [Pull Request](https://github.com/david-bai00/PrivyDrop/pulls)!
|
||||
- 目标:把文件/文本传输这件事做得轻盈、顺滑、可靠,且易于自托管。
|
||||
- 现状快照:断点续传、分块与背压、跨浏览器(含 Safari/Firefox)、Docker 一键部署。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成
|
||||
## 范围说明
|
||||
|
||||
- **核心架构重构 (2025年Q3)**: 成功地将整个前端代码库重构为现代化的分层架构。
|
||||
- 实现了一个与框架无关的**服务层** (`webrtcService`),用于封装所有 WebRTC 和业务逻辑。
|
||||
- 引入 **Zustand** (`fileTransferStore`) 进行中心化的、可预测的状态管理。
|
||||
- 将 UI 组件与业务逻辑解耦,建立了清晰的单向数据流。
|
||||
- **文件断点续传 (2025年Q3):** 实现了稳健的断点续传逻辑。通过设置保存目录,接收方能够检查已部分下载的文件,并仅请求缺失的数据块,极大地提升了大文件和不稳定网络下的传输成功率。
|
||||
- 范围:仅文件/文本传输(点对多),以房间为单位进行会话。
|
||||
|
||||
---
|
||||
|
||||
## 短期目标 (未来 1-3 个月)
|
||||
## 路线图
|
||||
|
||||
本阶段将专注于完善现有功能、提升核心体验的可靠性,为项目打下更坚实的基础。
|
||||
- P0 代码优化与瘦身
|
||||
|
||||
- **连接稳定性增强:** 目前,使用默认 4 位数字 ID 的房间已支持短时间(例如 15 分钟)内断线重连。未来将此特性扩展到自定义名称的房间,并提供更长的重连窗口(例如 1 小时)。
|
||||
- **精细化传输错误处理:** 当传输失败时,向用户提供更清晰、具体的反馈(例如:"对方已断开连接"、"浏览器存储空间不足"、"网络中断")。
|
||||
- 架构收敛与边界清晰:传输(发送/接收)、WebRTC 封装、状态管理与 UI 分离;拆分过大文件,公共类型/常量下沉共享。
|
||||
- 冗余清理与精简:移除死代码与未用导出;合并重复工具与重复逻辑(封包/解包保留权威实现)。
|
||||
- 配置与命名统一:分块大小/批大小/背压阈值来自单一配置源,仅统一来源,不改既有行为。
|
||||
- 状态管理收敛:以 Zustand 为唯一状态源;自定义 hooks 负责订阅与意图触发,不承载业务逻辑。
|
||||
- 异步与错误路径简化:统一 Promise/事件用法与返回值;集中错误类型与边界。
|
||||
- 日志与调试(本批重点):统一 logger(error/warn/info/debug + 开关),生产默认低噪;替换散落的 console/postLog,并在房间/会话/文件维度埋点一致。
|
||||
- 类型与构建健康度:收紧 TypeScript(小步),减少 any/隐式 any;保持 Lint/格式化一致。
|
||||
|
||||
- P0 最小测试集
|
||||
|
||||
- 单元测试:分块读取/切片、嵌包解析、顺序落盘器的乱序/重复/尾块处理等核心边界。
|
||||
- 轻量集成:伪造数据通道验证“发送 → 接收 → 落盘”的最小闭环,覆盖背压等待与断点恢复路径。
|
||||
- 后端最小单测:房间与速率限制的关键契约。
|
||||
|
||||
- P1 错误体验与只读网络体检
|
||||
|
||||
- 错误提示:用语清晰、可重试建议;显示发送/接收状态与失败简述。
|
||||
- 只读体检:展示连接状态、数据通道状态、发送缓冲、当前/平均速率、最近错误;仅展示,不做复杂探测。
|
||||
|
||||
- P1 文档与部署一致性
|
||||
- 快速上手与 Docker 自托管流程对齐;常见问题与排错路径补充;截图与术语口径统一。
|
||||
- 前端架构文档与实现同步(Zustand + 自定义 Hooks)。
|
||||
|
||||
---
|
||||
|
||||
## 中期目标 (未来 3-9 个月)
|
||||
## 完成定义(达成即止)
|
||||
|
||||
本阶段将引入强大的新功能,将 PrivyDrop 的应用场景从一对一的文件传输拓展到更广阔的领域。
|
||||
- P0 代码优化与瘦身
|
||||
|
||||
- **[重大功能] P2P 群组聊天:** 虽然目前已支持多人加入同一房间,但此功能将增加一个简洁的、基于主持人的群聊系统。房间创建者将作为中心节点,负责将加密的文本和文件转发给所有其他参与者,实现基础的群组协作。
|
||||
- **阅后即焚的消息与文件:** 允许用户发送在被读取或设定时间后自动销毁的文件或文本消息。
|
||||
- **剪贴板同步:** 增加一个专门的模式,用于在连接的设备之间实时同步剪贴板内容(文本和图片)。
|
||||
- **官方 Docker 支持:** 提供并维护官方的 `Dockerfile` 和 `docker-compose.yml` 配置,实现一键部署整个技术栈(前端、后端、Redis、Coturn),极大地方便自托管用户。
|
||||
- **包体积优化:** 定期使用 `@next/bundle-analyzer` 分析前端打包体积,通过代码分割等手段进行优化,保持应用的轻量化。
|
||||
- 模块边界清晰、目录与命名统一;重复实现合并,死代码清理完毕。
|
||||
- 分块/批/背压等配置项有单一来源,保持现有行为不变。
|
||||
- Zustand 统一为状态源;组件无业务副作用;自定义 hooks 角色明确。
|
||||
- 日志体系可按等级与开关控制,生产默认低噪;无散落调试输出。
|
||||
- 构建与 Lint 通过;类型告警明显减少。
|
||||
|
||||
### 性能与部署
|
||||
|
||||
- **官方 Docker 支持:** 提供并维护官方的 `Dockerfile` 和 `docker-compose.yml` 配置,实现一键部署整个技术栈(前端、后端、Redis、Coturn),极大地方便自托管用户。
|
||||
- **包体积优化:** 定期使用 `@next/bundle-analyzer` 分析前端打包体积,通过代码分割等手段进行优化,保持应用的轻量化。
|
||||
|
||||
### 用户体验 (UX)
|
||||
|
||||
- to be define
|
||||
- P0 最小测试集
|
||||
- 关键模块单测覆盖核心边界;存在一个最小集成用例完成“发送 → 接收 → 落盘”。
|
||||
|
||||
---
|
||||
|
||||
## 未来探索与社区驱动
|
||||
## 术语口径
|
||||
|
||||
本部分用于记录那些不在当前核心规划中,但对社区贡献开放的绝佳想法。
|
||||
- 发送/接收(Sender/Receiver)
|
||||
- 房间(Room)
|
||||
- 分块(Chunk)与背压(Backpressure)
|
||||
- 断点续传(Resume)
|
||||
- 数据通道(DataChannel)
|
||||
- 落盘(OPFS/磁盘写入)
|
||||
|
||||
- **代码测试覆盖:** 近期的架构重构使得代码库的可测试性大大增强。我们现在计划引入测试框架(如 Vitest),为核心的 `webrtcService` 和 Zustand store 添加单元测试,以提升代码质量、保障社区贡献的安全性。我们非常欢迎社区在此方面做出贡献。
|
||||
- **你的想法:** 你是否对浏览器扩展、屏幕共享、P2P 媒体流等高级功能有很棒的想法?欢迎通过 Issue 发起讨论!我们相信最好的创意来自社区。
|
||||
|
||||
## 如何贡献
|
||||
|
||||
你的贡献对于将这份路线图变为现实至关重要!
|
||||
|
||||
1. **认领任务:** 寻找被标记为 `help wanted` 或 `good first issue` 的 Issue。
|
||||
2. **发起讨论:** 如果你对路线图中某个项目感兴趣,欢迎发起一个讨论来分享你的想法。
|
||||
3. **提交代码:** Fork 仓库,创建你的功能分支,然后提交 Pull Request。
|
||||
|
||||
感谢你成为 PrivyDrop 社区的一员!让我们一起共创私人分享的未来。
|
||||
感谢关注与贡献!
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.npm
|
||||
.env*
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
coverage
|
||||
.nyc_output
|
||||
logs
|
||||
*.log
|
||||
@@ -0,0 +1,41 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG NO_PROXY
|
||||
|
||||
ENV http_proxy ${HTTP_PROXY} \
|
||||
https_proxy ${HTTPS_PROXY} \
|
||||
no_proxy ${NO_PROXY}
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Run stage
|
||||
FROM node:18-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy prebuilt artifacts
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY health-check.js ./
|
||||
|
||||
# Create user and set permissions
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S backend -u 1001 -G nodejs && \
|
||||
chown -R backend:nodejs /app
|
||||
|
||||
USER backend
|
||||
EXPOSE 3001
|
||||
|
||||
# Use a Node.js script for health checks (instead of curl)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD node health-check.js
|
||||
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -88,12 +88,18 @@ server {
|
||||
}
|
||||
# Next.js Image Optimization Service (usually handled by the Next.js application)
|
||||
location /_next/image {
|
||||
proxy_pass http://localhost:3000; # Point to the Next.js application
|
||||
proxy_pass http://localhost:3002; # Point to the Next.js application
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Add cache optimization for image optimization
|
||||
proxy_cache_valid 200 1d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 5s;
|
||||
}
|
||||
# 2. Handle static files under the public directory and Next.js dynamic requests
|
||||
# This location should be after specific proxies (like /api/, /socket.io/),
|
||||
@@ -110,7 +116,7 @@ server {
|
||||
}
|
||||
# Named location, used to proxy requests to the Next.js application
|
||||
location @nextjs_app {
|
||||
proxy_pass http://localhost:3000; # Point to the Next.js application
|
||||
proxy_pass http://localhost:3002; # Point to the Next.js application
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const options = {
|
||||
host: 'localhost',
|
||||
port: process.env.BACKEND_PORT || 3001,
|
||||
path: '/health',
|
||||
timeout: 2000,
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log('Health check passed');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`Health check failed with status: ${res.statusCode}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.log(`Health check failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log('Health check timeout');
|
||||
req.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.end();
|
||||
Generated
+156
-156
@@ -19,7 +19,7 @@ importers:
|
||||
version: 4.21.2
|
||||
ioredis:
|
||||
specifier: ^5.4.1
|
||||
version: 5.7.0
|
||||
version: 5.8.0
|
||||
socket.io:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
@@ -32,7 +32,7 @@ importers:
|
||||
version: 5.0.3
|
||||
'@types/node':
|
||||
specifier: ^22.13.4
|
||||
version: 22.17.2
|
||||
version: 22.18.6
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
@@ -41,10 +41,10 @@ importers:
|
||||
version: 6.0.1
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@22.17.2)(typescript@5.9.2)
|
||||
version: 10.9.2(@types/node@22.18.6)(typescript@5.9.2)
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.20.4
|
||||
version: 4.20.5
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.2
|
||||
@@ -55,164 +55,164 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
|
||||
'@esbuild/aix-ppc64@0.25.10':
|
||||
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
|
||||
'@esbuild/android-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
|
||||
'@esbuild/android-arm@0.25.10':
|
||||
resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
|
||||
'@esbuild/android-x64@0.25.10':
|
||||
resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
|
||||
'@esbuild/darwin-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
|
||||
'@esbuild/darwin-x64@0.25.10':
|
||||
resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
|
||||
'@esbuild/freebsd-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
|
||||
'@esbuild/freebsd-x64@0.25.10':
|
||||
resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
|
||||
'@esbuild/linux-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
|
||||
'@esbuild/linux-arm@0.25.10':
|
||||
resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
|
||||
'@esbuild/linux-ia32@0.25.10':
|
||||
resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
|
||||
'@esbuild/linux-loong64@0.25.10':
|
||||
resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
|
||||
'@esbuild/linux-mips64el@0.25.10':
|
||||
resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
|
||||
'@esbuild/linux-ppc64@0.25.10':
|
||||
resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
|
||||
'@esbuild/linux-riscv64@0.25.10':
|
||||
resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
|
||||
'@esbuild/linux-s390x@0.25.10':
|
||||
resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
|
||||
'@esbuild/linux-x64@0.25.10':
|
||||
resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
|
||||
'@esbuild/netbsd-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
|
||||
'@esbuild/netbsd-x64@0.25.10':
|
||||
resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
|
||||
'@esbuild/openbsd-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
|
||||
'@esbuild/openbsd-x64@0.25.10':
|
||||
resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
|
||||
'@esbuild/openharmony-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
|
||||
'@esbuild/sunos-x64@0.25.10':
|
||||
resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
|
||||
'@esbuild/win32-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
|
||||
'@esbuild/win32-ia32@0.25.10':
|
||||
resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
|
||||
'@esbuild/win32-x64@0.25.10':
|
||||
resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@ioredis/commands@1.3.0':
|
||||
resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==}
|
||||
'@ioredis/commands@1.4.0':
|
||||
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
|
||||
|
||||
'@isaacs/balanced-match@4.0.1':
|
||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||
@@ -272,8 +272,8 @@ packages:
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/node@22.17.2':
|
||||
resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==}
|
||||
'@types/node@22.18.6':
|
||||
resolution: {integrity: sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
@@ -304,16 +304,16 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.0:
|
||||
resolution: {integrity: sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==}
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.1:
|
||||
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
arg@4.1.3:
|
||||
@@ -405,8 +405,8 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
@@ -478,8 +478,8 @@ packages:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.25.9:
|
||||
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
|
||||
esbuild@0.25.10:
|
||||
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -557,8 +557,8 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ioredis@5.7.0:
|
||||
resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==}
|
||||
ioredis@5.8.0:
|
||||
resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
@@ -582,8 +582,8 @@ packages:
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lru-cache@11.1.0:
|
||||
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
|
||||
lru-cache@11.2.1:
|
||||
resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
make-error@1.3.6:
|
||||
@@ -772,8 +772,8 @@ packages:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.0:
|
||||
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
@@ -794,8 +794,8 @@ packages:
|
||||
'@swc/wasm':
|
||||
optional: true
|
||||
|
||||
tsx@4.20.4:
|
||||
resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==}
|
||||
tsx@4.20.5:
|
||||
resolution: {integrity: sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -861,85 +861,85 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
'@esbuild/aix-ppc64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
'@esbuild/android-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
'@esbuild/android-arm@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
'@esbuild/android-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
'@esbuild/darwin-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
'@esbuild/darwin-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
'@esbuild/freebsd-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
'@esbuild/freebsd-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
'@esbuild/linux-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
'@esbuild/linux-arm@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
'@esbuild/linux-ia32@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
'@esbuild/linux-loong64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
'@esbuild/linux-mips64el@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
'@esbuild/linux-ppc64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
'@esbuild/linux-riscv64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
'@esbuild/linux-s390x@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
'@esbuild/linux-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
'@esbuild/netbsd-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
'@esbuild/netbsd-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
'@esbuild/openbsd-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
'@esbuild/openbsd-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
'@esbuild/openharmony-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
'@esbuild/sunos-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
'@esbuild/win32-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
'@esbuild/win32-ia32@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
'@esbuild/win32-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@ioredis/commands@1.3.0': {}
|
||||
'@ioredis/commands@1.4.0': {}
|
||||
|
||||
'@isaacs/balanced-match@4.0.1': {}
|
||||
|
||||
@@ -951,7 +951,7 @@ snapshots:
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
@@ -978,19 +978,19 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/express-serve-static-core@5.0.7':
|
||||
dependencies:
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.5
|
||||
@@ -1005,7 +1005,7 @@ snapshots:
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/node@22.17.2':
|
||||
'@types/node@22.18.6':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
@@ -1016,12 +1016,12 @@ snapshots:
|
||||
'@types/send@0.17.5':
|
||||
dependencies:
|
||||
'@types/mime': 1.3.5
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/serve-static@1.15.8':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
'@types/send': 0.17.5
|
||||
|
||||
accepts@1.3.8:
|
||||
@@ -1037,13 +1037,13 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.0: {}
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.1: {}
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
arg@4.1.3: {}
|
||||
|
||||
@@ -1125,7 +1125,7 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1:
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
@@ -1162,7 +1162,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
@@ -1183,34 +1183,34 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
esbuild@0.25.9:
|
||||
esbuild@0.25.10:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.9
|
||||
'@esbuild/android-arm': 0.25.9
|
||||
'@esbuild/android-arm64': 0.25.9
|
||||
'@esbuild/android-x64': 0.25.9
|
||||
'@esbuild/darwin-arm64': 0.25.9
|
||||
'@esbuild/darwin-x64': 0.25.9
|
||||
'@esbuild/freebsd-arm64': 0.25.9
|
||||
'@esbuild/freebsd-x64': 0.25.9
|
||||
'@esbuild/linux-arm': 0.25.9
|
||||
'@esbuild/linux-arm64': 0.25.9
|
||||
'@esbuild/linux-ia32': 0.25.9
|
||||
'@esbuild/linux-loong64': 0.25.9
|
||||
'@esbuild/linux-mips64el': 0.25.9
|
||||
'@esbuild/linux-ppc64': 0.25.9
|
||||
'@esbuild/linux-riscv64': 0.25.9
|
||||
'@esbuild/linux-s390x': 0.25.9
|
||||
'@esbuild/linux-x64': 0.25.9
|
||||
'@esbuild/netbsd-arm64': 0.25.9
|
||||
'@esbuild/netbsd-x64': 0.25.9
|
||||
'@esbuild/openbsd-arm64': 0.25.9
|
||||
'@esbuild/openbsd-x64': 0.25.9
|
||||
'@esbuild/openharmony-arm64': 0.25.9
|
||||
'@esbuild/sunos-x64': 0.25.9
|
||||
'@esbuild/win32-arm64': 0.25.9
|
||||
'@esbuild/win32-ia32': 0.25.9
|
||||
'@esbuild/win32-x64': 0.25.9
|
||||
'@esbuild/aix-ppc64': 0.25.10
|
||||
'@esbuild/android-arm': 0.25.10
|
||||
'@esbuild/android-arm64': 0.25.10
|
||||
'@esbuild/android-x64': 0.25.10
|
||||
'@esbuild/darwin-arm64': 0.25.10
|
||||
'@esbuild/darwin-x64': 0.25.10
|
||||
'@esbuild/freebsd-arm64': 0.25.10
|
||||
'@esbuild/freebsd-x64': 0.25.10
|
||||
'@esbuild/linux-arm': 0.25.10
|
||||
'@esbuild/linux-arm64': 0.25.10
|
||||
'@esbuild/linux-ia32': 0.25.10
|
||||
'@esbuild/linux-loong64': 0.25.10
|
||||
'@esbuild/linux-mips64el': 0.25.10
|
||||
'@esbuild/linux-ppc64': 0.25.10
|
||||
'@esbuild/linux-riscv64': 0.25.10
|
||||
'@esbuild/linux-s390x': 0.25.10
|
||||
'@esbuild/linux-x64': 0.25.10
|
||||
'@esbuild/netbsd-arm64': 0.25.10
|
||||
'@esbuild/netbsd-x64': 0.25.10
|
||||
'@esbuild/openbsd-arm64': 0.25.10
|
||||
'@esbuild/openbsd-x64': 0.25.10
|
||||
'@esbuild/openharmony-arm64': 0.25.10
|
||||
'@esbuild/sunos-x64': 0.25.10
|
||||
'@esbuild/win32-arm64': 0.25.10
|
||||
'@esbuild/win32-ia32': 0.25.10
|
||||
'@esbuild/win32-x64': 0.25.10
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
@@ -1331,11 +1331,11 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ioredis@5.7.0:
|
||||
ioredis@5.8.0:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.3.0
|
||||
'@ioredis/commands': 1.4.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
@@ -1359,7 +1359,7 @@ snapshots:
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lru-cache@11.1.0: {}
|
||||
lru-cache@11.2.1: {}
|
||||
|
||||
make-error@1.3.6: {}
|
||||
|
||||
@@ -1407,7 +1407,7 @@ snapshots:
|
||||
|
||||
path-scurry@2.0.0:
|
||||
dependencies:
|
||||
lru-cache: 11.1.0
|
||||
lru-cache: 11.2.1
|
||||
minipass: 7.1.2
|
||||
|
||||
path-to-regexp@0.1.12: {}
|
||||
@@ -1556,26 +1556,26 @@ snapshots:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.0:
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.0
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2):
|
||||
ts-node@10.9.2(@types/node@22.18.6)(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
'@tsconfig/node10': 1.0.11
|
||||
'@tsconfig/node12': 1.0.11
|
||||
'@tsconfig/node14': 1.0.3
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.4
|
||||
arg: 4.1.3
|
||||
@@ -1586,9 +1586,9 @@ snapshots:
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
|
||||
tsx@4.20.4:
|
||||
tsx@4.20.5:
|
||||
dependencies:
|
||||
esbuild: 0.25.9
|
||||
esbuild: 0.25.10
|
||||
get-tsconfig: 4.10.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
@@ -1622,9 +1622,9 @@ snapshots:
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.1
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
ws@8.17.1: {}
|
||||
|
||||
|
||||
@@ -3,17 +3,30 @@ import { CONFIG } from "./env";
|
||||
|
||||
// Define the sources allowed in the development environment
|
||||
const DEV_ORIGINS = [
|
||||
CONFIG.CORS_ORIGIN, // http://localhost:3002
|
||||
'http://localhost:3000', // alternate port
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3000$/, // LAN addresses
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3002$/ // LAN addresses with new port
|
||||
CONFIG.CORS_ORIGIN, // http://localhost:3002
|
||||
"http://localhost:3002", // alternate port
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3002$/, // LAN addresses
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3002$/, // LAN addresses with new port
|
||||
];
|
||||
|
||||
// Parse multi-origin config in production (comma-separated)
|
||||
const parseProdOrigins = (): string | RegExp | (string | RegExp)[] => {
|
||||
const v = CONFIG.CORS_ORIGIN?.trim();
|
||||
if (!v) return DEV_ORIGINS; // Fallback to the development whitelist
|
||||
if (v.includes(",")) {
|
||||
return v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
// Configure CORS
|
||||
export const corsOptions: CorsOptions =
|
||||
CONFIG.NODE_ENV === "production"
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN,
|
||||
origin: parseProdOrigins(),
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
credentials: true,
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
@@ -28,7 +41,7 @@ export const corsOptions: CorsOptions =
|
||||
export const corsWSOptions =
|
||||
CONFIG.NODE_ENV === "production"
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN, // Allowed origin, replace with your Next.js application's URL
|
||||
origin: parseProdOrigins(),
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
}
|
||||
|
||||
@@ -47,16 +47,21 @@ const createRoomHandler: RequestHandler<{}, any, CreateRoomRequest> = async (
|
||||
|
||||
try {
|
||||
const exists = await roomService.isRoomExist(roomId);
|
||||
const response = {
|
||||
success: !exists,
|
||||
message: exists ? "roomId is already exists" : "create room success",
|
||||
};
|
||||
|
||||
if (!exists) {
|
||||
await roomService.createRoom(roomId);
|
||||
// Idempotent behavior for long IDs (>= 8): allow reuse for reconnect scenarios.
|
||||
// Short IDs keep strict uniqueness (prevent accidental collisions).
|
||||
if (exists) {
|
||||
if (roomId.length >= 8) {
|
||||
// Do NOT refresh TTL here; actual join will refresh on success.
|
||||
res.json({ success: true, message: "room exists (rejoin allowed)" });
|
||||
return;
|
||||
}
|
||||
res.json({ success: false, message: "roomId is already exists" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
await roomService.createRoom(roomId);
|
||||
res.json({ success: true, message: "create room success" });
|
||||
} catch (error) {
|
||||
console.error("Error checking room:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { redis } from '../services/redis';
|
||||
import { CONFIG } from '../config/env';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 应用启动时间
|
||||
const startTime = Date.now();
|
||||
|
||||
// 基础健康检查
|
||||
router.get('/health', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-backend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: CONFIG.NODE_ENV
|
||||
};
|
||||
|
||||
res.status(200).json(health);
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-backend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API路径的健康检查 (兼容性)
|
||||
router.get('/api/health', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-backend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: CONFIG.NODE_ENV
|
||||
};
|
||||
|
||||
res.status(200).json(health);
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-backend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 详细健康检查
|
||||
router.get('/health/detailed', async (req: Request, res: Response) => {
|
||||
const errors: string[] = [];
|
||||
let status = 'healthy';
|
||||
|
||||
try {
|
||||
// 基础信息
|
||||
const basicHealth = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-backend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: CONFIG.NODE_ENV
|
||||
};
|
||||
|
||||
// 检查Redis连接
|
||||
const redisHealth = await checkRedisHealth();
|
||||
if (redisHealth.status !== 'connected') {
|
||||
errors.push('Redis connection failed');
|
||||
status = 'unhealthy';
|
||||
}
|
||||
|
||||
// 检查Socket.IO状态
|
||||
const io = req.app.get('io');
|
||||
const socketHealth = {
|
||||
status: io ? 'running' : 'not_initialized',
|
||||
connections: io ? io.engine.clientsCount : 0
|
||||
};
|
||||
|
||||
// 获取系统资源信息
|
||||
const systemInfo = getSystemInfo();
|
||||
|
||||
// 检查系统资源
|
||||
if (systemInfo.memory.percent > 90) {
|
||||
errors.push('High memory usage (>90%)');
|
||||
status = status === 'healthy' ? 'degraded' : status;
|
||||
}
|
||||
|
||||
if (systemInfo.cpu.percent > 80) {
|
||||
errors.push('High CPU usage (>80%)');
|
||||
status = status === 'healthy' ? 'degraded' : status;
|
||||
}
|
||||
|
||||
const detailedHealth = {
|
||||
...basicHealth,
|
||||
status,
|
||||
dependencies: {
|
||||
redis: redisHealth,
|
||||
socketio: socketHealth
|
||||
},
|
||||
system: systemInfo,
|
||||
...(errors.length > 0 && { errors })
|
||||
};
|
||||
|
||||
const httpStatus = status === 'healthy' ? 200 : 503;
|
||||
res.status(httpStatus).json(detailedHealth);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Detailed health check error:', error);
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-backend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Redis健康检查函数
|
||||
async function checkRedisHealth() {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await redis.ping();
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
return {
|
||||
status: 'connected',
|
||||
responseTime,
|
||||
host: CONFIG.REDIS.HOST,
|
||||
port: CONFIG.REDIS.PORT
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'disconnected',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
host: CONFIG.REDIS.HOST,
|
||||
port: CONFIG.REDIS.PORT
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 系统信息获取函数
|
||||
function getSystemInfo() {
|
||||
const memUsage = process.memoryUsage();
|
||||
const totalMem = memUsage.heapTotal;
|
||||
const usedMem = memUsage.heapUsed;
|
||||
const freeMem = totalMem - usedMem;
|
||||
|
||||
return {
|
||||
memory: {
|
||||
used: formatBytes(usedMem),
|
||||
free: formatBytes(freeMem),
|
||||
total: formatBytes(totalMem),
|
||||
percent: Math.round((usedMem / totalMem) * 100)
|
||||
},
|
||||
cpu: {
|
||||
percent: getCpuUsage()
|
||||
},
|
||||
uptime: process.uptime(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
nodeVersion: process.version
|
||||
};
|
||||
}
|
||||
|
||||
// 格式化字节数
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 简化的CPU使用率 (基于进程CPU时间)
|
||||
function getCpuUsage(): number {
|
||||
const cpuUsage = process.cpuUsage();
|
||||
const totalCpuTime = (cpuUsage.user + cpuUsage.system) / 1000000; // 转换为秒
|
||||
const uptime = process.uptime();
|
||||
return Math.round((totalCpuTime / uptime) * 100);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import { Server } from "socket.io"; // socket.io: A library for real-time web ap
|
||||
import { CONFIG } from "./config/env";
|
||||
import { corsOptions, corsWSOptions } from "./config/server";
|
||||
import apiRouter from "./routes/api";
|
||||
import healthRouter from "./routes/health";
|
||||
import { setupSocketHandlers } from "./socket/handlers";
|
||||
|
||||
const app = express(); // Create an Express application
|
||||
@@ -19,6 +20,10 @@ setupSocketHandlers(io);
|
||||
// Make io instance available to routes
|
||||
app.set('io', io);
|
||||
|
||||
// Register health check routes first (for Docker health checks)
|
||||
app.use(healthRouter);
|
||||
|
||||
// Register API routes
|
||||
app.use(apiRouter);
|
||||
|
||||
server.listen(CONFIG.BACKEND_PORT, () => {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Check if a build package already exists
|
||||
if [ -f "out.zip" ]; then
|
||||
echo "📦 Detected existing build package: out.zip"
|
||||
echo "📦 Package size: $(du -sh out.zip | cut -f1)"
|
||||
echo "📝 Build info:"
|
||||
if [ -f "out/deploy-info.txt" ]; then
|
||||
cat out/deploy-info.txt
|
||||
fi
|
||||
echo ""
|
||||
echo "⚠️ Choose an option:"
|
||||
echo " 1. Deploy existing package"
|
||||
echo " 2. Rebuild and deploy"
|
||||
echo " 3. Exit"
|
||||
echo ""
|
||||
read -p "Select (1/2/3): " -n 1 -r
|
||||
echo ""
|
||||
|
||||
case $REPLY in
|
||||
1)
|
||||
echo "🚀 Deploying existing package..."
|
||||
DEPLOY_EXISTING=true
|
||||
;;
|
||||
2)
|
||||
echo "🔄 Rebuilding..."
|
||||
rm -rf out out.zip
|
||||
;;
|
||||
3)
|
||||
echo "👋 Exit"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Invalid option, aborting"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "${DEPLOY_EXISTING:-}" != "true" ]; then
|
||||
echo "🚀 Start local build..."
|
||||
|
||||
# Clean previous build outputs
|
||||
echo "🧹 Cleaning previous build outputs..."
|
||||
rm -rf frontend/.next
|
||||
rm -rf backend/dist
|
||||
rm -rf out
|
||||
|
||||
# Create output directory for packaging
|
||||
mkdir -p out
|
||||
|
||||
# Build frontend
|
||||
echo "📦 Building frontend..."
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm build
|
||||
cd ..
|
||||
|
||||
# Build backend
|
||||
echo "📦 Building backend..."
|
||||
cd backend
|
||||
pnpm install
|
||||
pnpm build
|
||||
cd ..
|
||||
|
||||
# Prepare deploy bundle
|
||||
echo "📋 Preparing deploy bundle..."
|
||||
mkdir -p out/frontend
|
||||
mkdir -p out/backend
|
||||
|
||||
# Copy frontend artifacts
|
||||
cp -r frontend/.next out/frontend/
|
||||
cp frontend/package.json out/frontend/
|
||||
cp -r frontend/public out/frontend/ 2>/dev/null || true
|
||||
cp -r frontend/app out/frontend/ 2>/dev/null || true
|
||||
cp -r frontend/components out/frontend/ 2>/dev/null || true
|
||||
cp -r frontend/lib out/frontend/ 2>/dev/null || true
|
||||
cp -r frontend/styles out/frontend/ 2>/dev/null || true
|
||||
cp frontend/next.config.js out/frontend/ 2>/dev/null || true
|
||||
cp frontend/tailwind.config.ts out/frontend/ 2>/dev/null || true
|
||||
cp frontend/postcss.config.js out/frontend/ 2>/dev/null || true
|
||||
cp -r frontend/content out/frontend/ 2>/dev/null || true
|
||||
|
||||
# Copy backend artifacts
|
||||
cp -r backend/dist out/backend/
|
||||
cp backend/package.json out/backend/
|
||||
|
||||
|
||||
# Write deployment info
|
||||
echo "📝 Writing deployment info..."
|
||||
cat > out/deploy-info.txt << EOF
|
||||
Build time: $(date)
|
||||
Git commit: $(git rev-parse --short HEAD)
|
||||
Git branch: $(git branch --show-current)
|
||||
Frontend BUILD_ID: $(cat frontend/.next/BUILD_ID 2>/dev/null || echo "N/A")
|
||||
EOF
|
||||
|
||||
# Archive deploy bundle
|
||||
echo "📦 Archiving deploy bundle..."
|
||||
cd out
|
||||
zip -r ../out.zip .
|
||||
cd ..
|
||||
|
||||
echo "✅ Local build and packaging completed!"
|
||||
echo "📦 Package: out.zip"
|
||||
echo "📦 Size: $(du -sh out.zip | cut -f1)"
|
||||
fi
|
||||
|
||||
# Deploy logic
|
||||
if [ -f "out.zip" ]; then
|
||||
echo ""
|
||||
echo "🚀 Detected out.zip, ready to deploy to server"
|
||||
echo "⚠️ Deployment will:"
|
||||
echo " 1. Upload out.zip to server"
|
||||
echo " 2. Backup current version"
|
||||
echo " 3. Unzip and replace files"
|
||||
echo " 4. Restart PM2 apps"
|
||||
echo ""
|
||||
read -p "Proceed with deployment? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "🚀 Starting deployment..."
|
||||
|
||||
# Load deploy config file
|
||||
if [ -f "deploy.config" ]; then
|
||||
source deploy.config
|
||||
fi
|
||||
|
||||
# Validate required environment variables
|
||||
if [ -z "$DEPLOY_SERVER" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_PATH" ]; then
|
||||
echo "❌ Missing server configuration. Please configure one of the following:"
|
||||
echo " 1. Copy deploy.config.example to deploy.config and edit values"
|
||||
echo " 2. Or set environment variables:"
|
||||
echo " export DEPLOY_SERVER=your-server-ip"
|
||||
echo " export DEPLOY_USER=root"
|
||||
echo " export DEPLOY_PATH=/root/PrivyDrop"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build SSH options (port/key)
|
||||
SSH_OPTS=""
|
||||
SCP_OPTS=""
|
||||
if [ -n "${SSH_PORT:-}" ]; then
|
||||
SSH_OPTS+=" -p $SSH_PORT"
|
||||
SCP_OPTS+=" -P $SSH_PORT"
|
||||
fi
|
||||
if [ -n "${SSH_KEY_PATH:-}" ]; then
|
||||
SSH_OPTS+=" -i $SSH_KEY_PATH"
|
||||
SCP_OPTS+=" -i $SSH_KEY_PATH"
|
||||
fi
|
||||
|
||||
# Upload build package to server
|
||||
echo "📤 Uploading package to server..."
|
||||
# shellcheck disable=SC2086
|
||||
scp $SCP_OPTS out.zip $DEPLOY_USER@$DEPLOY_SERVER:/tmp/
|
||||
|
||||
# Run remote deployment (fix: ensure heredoc script actually executes)
|
||||
echo "🔧 Executing remote deployment..."
|
||||
# Inject DEPLOY_PATH and execute heredoc via 'bash -s' on remote host
|
||||
# shellcheck disable=SC2086
|
||||
ssh $SSH_OPTS $DEPLOY_USER@$DEPLOY_SERVER "DEPLOY_PATH='$DEPLOY_PATH' bash -s" << 'EOF'
|
||||
set -euo pipefail
|
||||
# Create structured backup directory
|
||||
BACKUP_ROOT="/tmp/privydrop_backup"
|
||||
BACKUP_DIR="$BACKUP_ROOT/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR/frontend" "$BACKUP_DIR/backend"
|
||||
|
||||
# Backup current artifacts if present
|
||||
if [ -d "$DEPLOY_PATH/frontend/.next" ]; then
|
||||
echo "📋 Backing up current frontend build..."
|
||||
mv "$DEPLOY_PATH/frontend/.next" "$BACKUP_DIR/frontend/.next"
|
||||
fi
|
||||
if [ -d "$DEPLOY_PATH/backend/dist" ]; then
|
||||
echo "📋 Backing up current backend build..."
|
||||
mv "$DEPLOY_PATH/backend/dist" "$BACKUP_DIR/backend/dist"
|
||||
fi
|
||||
|
||||
# Stop PM2 processes
|
||||
echo "⏹️ Stopping PM2 apps..."
|
||||
sudo pm2 stop all || true
|
||||
sudo pm2 delete all || true
|
||||
|
||||
# Extract new version
|
||||
echo "📂 Extracting new version..."
|
||||
cd "$DEPLOY_PATH"
|
||||
unzip -o /tmp/out.zip
|
||||
rm -f /tmp/out.zip
|
||||
|
||||
# Fix ownership
|
||||
sudo chown -R "$(id -un)":"$(id -gn)" "$DEPLOY_PATH/frontend/.next" 2>/dev/null || true
|
||||
sudo chown -R "$(id -un)":"$(id -gn)" "$DEPLOY_PATH/backend/dist" 2>/dev/null || true
|
||||
|
||||
# Start PM2 apps
|
||||
echo "▶️ Starting PM2 apps..."
|
||||
sudo pm2 start ecosystem.config.js
|
||||
|
||||
# Wait for services to start
|
||||
sleep 5
|
||||
|
||||
# Check PM2 status
|
||||
echo "🔍 Checking PM2 status..."
|
||||
sudo pm2 status
|
||||
|
||||
# Print version identifiers for verification
|
||||
if [ -f "$DEPLOY_PATH/frontend/.next/BUILD_ID" ]; then
|
||||
echo "📦 Frontend BUILD_ID: $(cat "$DEPLOY_PATH/frontend/.next/BUILD_ID")"
|
||||
fi
|
||||
if [ -f "$DEPLOY_PATH/deploy-info.txt" ]; then
|
||||
echo "📝 Deploy info:"
|
||||
cat "$DEPLOY_PATH/deploy-info.txt" || true
|
||||
fi
|
||||
|
||||
echo "✅ Deployment completed!"
|
||||
echo "📋 Backup saved at: $BACKUP_DIR"
|
||||
EOF
|
||||
|
||||
echo "🎉 Deployment finished. Check PM2 status on server:"
|
||||
echo " ssh $DEPLOY_USER@$DEPLOY_SERVER 'sudo pm2 status'"
|
||||
else
|
||||
echo "❌ Deployment canceled"
|
||||
fi
|
||||
else
|
||||
echo "❌ out.zip not found"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,19 @@
|
||||
# Deployment configuration
|
||||
# Copy this file to 'deploy.config' and fill in your server details
|
||||
|
||||
# Server IP or domain
|
||||
DEPLOY_SERVER="your-server-ip"
|
||||
|
||||
# Server username (default: root)
|
||||
# Note: Using 'ssh root' is recommended here for simplicity. Ensure you understand the
|
||||
# security implications and restrict access appropriately (keys, firewall, etc.).
|
||||
DEPLOY_USER="root"
|
||||
|
||||
# Deploy path on the server (project root)
|
||||
DEPLOY_PATH="/root/PrivyDrop"
|
||||
|
||||
# SSH port (optional, default 22)
|
||||
# SSH_PORT="22"
|
||||
|
||||
# SSH private key path (optional)
|
||||
# SSH_KEY_PATH="~/.ssh/id_rsa"
|
||||
@@ -0,0 +1,762 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit immediately on error
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCKER_SCRIPTS_DIR="$SCRIPT_DIR/docker/scripts"
|
||||
|
||||
# Logging helpers
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
cat << EOF
|
||||
PrivyDrop Docker Deployment Script
|
||||
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--domain DOMAIN Specify domain (for HTTPS deployments)
|
||||
--mode MODE Deployment mode: lan-http|lan-tls|public|full
|
||||
lan-http: Intranet HTTP (fast start; no TLS)
|
||||
lan-tls: Intranet HTTPS (self-signed; dev/managed env only)
|
||||
public: Public HTTP + TURN server
|
||||
full: Domain + HTTPS (Let’s Encrypt) + TURN server
|
||||
--with-nginx Enable Nginx reverse proxy
|
||||
--with-turn Enable TURN server
|
||||
--with-sni443 Force enable 443 SNI routing (full; default enabled)
|
||||
--no-sni443 Disable 443 SNI routing (full; web listens directly on 443)
|
||||
--enable-web-https In lan-tls mode, enable self-signed HTTPS on 8443 (no HSTS)
|
||||
--le-email EMAIL Email for Let's Encrypt (recommended in full mode)
|
||||
--test-renewal Run 'certbot renew --dry-run' and reload services (verification)
|
||||
--clean Clean existing containers and data
|
||||
--help Show help
|
||||
|
||||
Examples:
|
||||
$0 --mode lan-http # LAN HTTP quick start
|
||||
$0 --mode lan-http --with-turn # LAN HTTP with TURN (NAT-friendly)
|
||||
$0 --mode lan-tls --enable-web-https # LAN HTTPS (self-signed) on 8443 (dev/managed)
|
||||
$0 --mode public --with-turn # Public deployment + TURN server (no domain)
|
||||
$0 --mode full --domain example.com \\
|
||||
--with-nginx --with-turn --le-email you@domain.com # Full HTTPS deployment (LE auto-issue/renew)
|
||||
$0 --clean # Clean deployment
|
||||
|
||||
Requirements:
|
||||
- Docker Engine and Docker Compose V2 (command `docker compose`)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse command-line arguments
|
||||
parse_arguments() {
|
||||
DOMAIN_NAME=""
|
||||
DEPLOYMENT_MODE=""
|
||||
WITH_NGINX=false
|
||||
WITH_TURN=false
|
||||
CLEAN_MODE=false
|
||||
LE_EMAIL=""
|
||||
WITH_SNI443=false
|
||||
DISABLE_SNI443=false
|
||||
ENABLE_WEB_HTTPS=false
|
||||
TEST_RENEWAL=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--domain)
|
||||
DOMAIN_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mode)
|
||||
DEPLOYMENT_MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--with-nginx)
|
||||
WITH_NGINX=true
|
||||
shift
|
||||
;;
|
||||
--with-turn)
|
||||
WITH_TURN=true
|
||||
shift
|
||||
;;
|
||||
--with-sni443)
|
||||
WITH_SNI443=true
|
||||
shift
|
||||
;;
|
||||
--no-sni443)
|
||||
DISABLE_SNI443=true
|
||||
shift
|
||||
;;
|
||||
--enable-web-https)
|
||||
ENABLE_WEB_HTTPS=true
|
||||
shift
|
||||
;;
|
||||
--le-email)
|
||||
LE_EMAIL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--test-renewal)
|
||||
TEST_RENEWAL=true
|
||||
shift
|
||||
;;
|
||||
--clean)
|
||||
CLEAN_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown argument: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Export variables for other scripts
|
||||
export DOMAIN_NAME
|
||||
export DEPLOYMENT_MODE
|
||||
export WITH_NGINX
|
||||
export WITH_TURN
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies() {
|
||||
log_info "Checking dependencies..."
|
||||
|
||||
local missing_deps=()
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
missing_deps+=("docker")
|
||||
fi
|
||||
|
||||
if ! docker compose version &> /dev/null; then
|
||||
missing_deps+=("docker compose (V2)")
|
||||
fi
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
missing_deps+=("curl")
|
||||
fi
|
||||
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
missing_deps+=("openssl")
|
||||
fi
|
||||
|
||||
if [[ ${#missing_deps[@]} -gt 0 ]]; then
|
||||
log_error "Missing dependencies: ${missing_deps[*]}"
|
||||
echo ""
|
||||
echo "Please install the missing dependencies:"
|
||||
for dep in "${missing_deps[@]}"; do
|
||||
case $dep in
|
||||
docker)
|
||||
echo " Docker: https://docs.docker.com/get-docker/"
|
||||
;;
|
||||
"docker compose (V2)")
|
||||
echo " Docker Compose V2 plugin: https://docs.docker.com/compose/install/"
|
||||
;;
|
||||
curl)
|
||||
echo " curl: sudo apt-get install curl (Ubuntu/Debian)"
|
||||
;;
|
||||
openssl)
|
||||
echo " openssl: sudo apt-get install openssl (Ubuntu/Debian)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Dependency checks passed"
|
||||
}
|
||||
|
||||
# Install and prepare Let's Encrypt (certbot)
|
||||
ensure_certbot() {
|
||||
if command -v certbot >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
log_info "Installing certbot (requires sudo)..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update -y && sudo apt-get install -y certbot
|
||||
else
|
||||
log_error "apt-get not found. Please install certbot manually or run on a supported system"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Write certbot deploy hook: copy certs and hot-reload services after renewal
|
||||
install_certbot_deploy_hook() {
|
||||
local repo_dir="$SCRIPT_DIR"
|
||||
local hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
|
||||
local hook_file="$hook_dir/privydrop-reload.sh"
|
||||
local compose_file="$repo_dir/docker-compose.yml"
|
||||
|
||||
sudo mkdir -p "$hook_dir"
|
||||
sudo bash -c "cat > '$hook_file'" << EOF
|
||||
#!/bin/bash
|
||||
set -e
|
||||
REPO_DIR="$repo_dir"
|
||||
COMPOSE_FILE="$compose_file"
|
||||
|
||||
# RENEWED_LINEAGE is provided by certbot and points to live/<domain>
|
||||
if [[ -z "\$RENEWED_LINEAGE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp "\$RENEWED_LINEAGE/fullchain.pem" "\$REPO_DIR/docker/ssl/server-cert.pem"
|
||||
cp "\$RENEWED_LINEAGE/privkey.pem" "\$REPO_DIR/docker/ssl/server-key.pem"
|
||||
chmod 600 "\$REPO_DIR/docker/ssl/server-key.pem" || true
|
||||
|
||||
# Hot-reload nginx; restart if it fails
|
||||
docker compose -f "\$COMPOSE_FILE" exec -T nginx nginx -s reload 2>/dev/null || \
|
||||
docker compose -f "\$COMPOSE_FILE" restart nginx || true
|
||||
|
||||
# Prefer sending HUP to coturn; restart if needed (ignore if disabled)
|
||||
docker compose -f "\$COMPOSE_FILE" exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || \
|
||||
docker compose -f "\$COMPOSE_FILE" restart coturn || true
|
||||
EOF
|
||||
sudo chmod +x "$hook_file"
|
||||
|
||||
# Attempt to enable systemd timer
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl enable --now certbot.timer 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure renewal is scheduled daily: prefer systemd timer; fallback to cron
|
||||
ensure_renewal_scheduler() {
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl enable --now certbot.timer 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
# Fallback: cron job every 12 hours
|
||||
if [ -w /etc/cron.d ] || sudo test -d /etc/cron.d; then
|
||||
sudo bash -c 'cat > /etc/cron.d/certbot' << 'EOF'
|
||||
# Auto-renew Let's Encrypt certificates for PrivyDrop
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
|
||||
0 */12 * * * root certbot renew -q
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Dry-run renewal test
|
||||
test_renewal() {
|
||||
log_info "Running certbot dry-run renewal test..."
|
||||
if ! command -v certbot >/dev/null 2>&1; then
|
||||
log_error "certbot not installed. Run full mode issuance first or install certbot manually"
|
||||
return 1
|
||||
fi
|
||||
sudo certbot renew --dry-run || {
|
||||
log_error "certbot dry-run failed"
|
||||
return 1
|
||||
}
|
||||
# Attempt hot-reload of nginx and coturn similar to deploy-hook
|
||||
docker compose exec -T nginx nginx -s reload 2>/dev/null || docker compose restart nginx || true
|
||||
docker compose exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || docker compose restart coturn || true
|
||||
log_success "Dry-run renewal completed; services reloaded"
|
||||
}
|
||||
|
||||
# Issue via webroot and enable 443 config
|
||||
provision_letsencrypt_cert() {
|
||||
# Only in full mode with nginx enabled and domain set
|
||||
if [[ "$DEPLOYMENT_MODE" != "full" || "$WITH_NGINX" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$DOMAIN_NAME" ]]; then
|
||||
log_warning "Full mode without --domain; skipping Let's Encrypt"
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$LE_EMAIL" ]]; then
|
||||
log_warning "No --le-email specified; using --register-unsafely-without-email"
|
||||
fi
|
||||
|
||||
ensure_certbot
|
||||
install_certbot_deploy_hook
|
||||
ensure_renewal_scheduler
|
||||
|
||||
mkdir -p docker/letsencrypt-www docker/ssl
|
||||
|
||||
# If certificates already exist (including -0001 lineage), skip issuance
|
||||
if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]] || ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* >/dev/null 2>&1; then
|
||||
log_info "Detected existing certificates/lineage; skipping initial issuance"
|
||||
else
|
||||
log_info "Issuing Let's Encrypt certificate via webroot..."
|
||||
local email_args="--email $LE_EMAIL"
|
||||
if [[ -z "$LE_EMAIL" ]]; then
|
||||
email_args="--register-unsafely-without-email"
|
||||
fi
|
||||
# Requires port 80 reachable and nginx running
|
||||
sudo certbot certonly --webroot -w "$(pwd)/docker/letsencrypt-www" \
|
||||
-d "$DOMAIN_NAME" -d "turn.$DOMAIN_NAME" \
|
||||
$email_args --agree-tos --non-interactive || {
|
||||
log_error "Certificate issuance failed; please check certbot output"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Resolve lineage directory (supports -0001/-0002 suffixes) and copy to docker/ssl
|
||||
local lineage_dir
|
||||
lineage_dir=$(readlink -f "/etc/letsencrypt/live/$DOMAIN_NAME" 2>/dev/null || true)
|
||||
if [[ -z "$lineage_dir" || ! -d "$lineage_dir" ]]; then
|
||||
lineage_dir=$(ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* 2>/dev/null | sort | tail -1)
|
||||
fi
|
||||
if [[ -z "$lineage_dir" || ! -f "$lineage_dir/fullchain.pem" ]]; then
|
||||
log_error "No valid certificate lineage directory found. Check /etc/letsencrypt/live/${DOMAIN_NAME}*"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo cp "$lineage_dir/fullchain.pem" docker/ssl/server-cert.pem
|
||||
sudo cp "$lineage_dir/privkey.pem" docker/ssl/server-key.pem
|
||||
sudo chmod 600 docker/ssl/server-key.pem || true
|
||||
|
||||
# Enable 443 config (certs ready): append only; pass SNI flag (enabled by default in full)
|
||||
local gen_args=(--mode full --domain "$DOMAIN_NAME" --no-clean --ssl-mode letsencrypt)
|
||||
[[ "$WITH_SNI443" == "true" ]] && gen_args+=(--enable-sni443)
|
||||
[[ "$DISABLE_SNI443" == "true" ]] && gen_args+=(--no-sni443)
|
||||
bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" "${gen_args[@]}" || true
|
||||
|
||||
# Hot-reload nginx to enable 443
|
||||
docker compose exec -T nginx nginx -s reload || docker compose restart nginx
|
||||
}
|
||||
|
||||
# Clean existing deployment
|
||||
clean_deployment() {
|
||||
if [[ "$CLEAN_MODE" == "true" ]]; then
|
||||
log_warning "Cleaning existing deployment..."
|
||||
|
||||
# Stop and remove containers
|
||||
if [[ -f "docker-compose.yml" ]]; then
|
||||
docker compose down -v --remove-orphans 2>/dev/null || true
|
||||
fi
|
||||
# After graceful stop, force-clean named containers as fallback
|
||||
docker stop -t 10 privydrop-nginx privydrop-coturn 2>/dev/null || true
|
||||
docker rm -f privydrop-nginx privydrop-coturn 2>/dev/null || true
|
||||
# Fallback: remove project network (if present)
|
||||
docker network rm privydrop_privydrop-network 2>/dev/null || true
|
||||
|
||||
# Remove images
|
||||
docker images | grep privydrop | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true
|
||||
|
||||
# Clean configuration files
|
||||
rm -rf docker/nginx/conf.d/*.conf docker/ssl/* logs/* .env 2>/dev/null || true
|
||||
|
||||
log_success "Cleanup complete"
|
||||
|
||||
if [[ $# -eq 1 ]]; then # If only --clean parameter
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure TURN service starts when requested (--with-turn)
|
||||
ensure_turn_running() {
|
||||
if [[ "$WITH_TURN" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
# If not running, start coturn via profile
|
||||
if ! docker compose ps | grep -q "privydrop-coturn"; then
|
||||
log_info "Starting TURN service (profile: turn)..."
|
||||
docker compose --profile turn up -d coturn || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Environment detection and configuration generation
|
||||
setup_environment() {
|
||||
log_info "Setting up environment..."
|
||||
|
||||
# Ensure scripts are executable
|
||||
chmod +x "$DOCKER_SCRIPTS_DIR"/*.sh 2>/dev/null || true
|
||||
|
||||
# Run environment detection
|
||||
local detect_args=""
|
||||
[[ -n "$DOMAIN_NAME" ]] && detect_args="--domain $DOMAIN_NAME"
|
||||
[[ -n "$DEPLOYMENT_MODE" ]] && detect_args="$detect_args --mode $DEPLOYMENT_MODE"
|
||||
[[ "$WITH_NGINX" == "true" ]] && detect_args="$detect_args --with-nginx"
|
||||
[[ "$WITH_SNI443" == "true" ]] && detect_args="$detect_args --enable-sni443"
|
||||
[[ "$DISABLE_SNI443" == "true" ]] && detect_args="$detect_args --no-sni443"
|
||||
[[ "$ENABLE_WEB_HTTPS" == "true" ]] && detect_args="$detect_args --enable-web-https"
|
||||
|
||||
if ! bash "$DOCKER_SCRIPTS_DIR/detect-environment.sh" $detect_args; then
|
||||
log_error "Environment detection failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate configuration files
|
||||
if ! bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" $detect_args; then
|
||||
log_error "Configuration generation failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Environment setup complete"
|
||||
}
|
||||
|
||||
# Build and start services
|
||||
deploy_services() {
|
||||
log_info "Building and starting services..."
|
||||
|
||||
# Ensure log directories exist and relax permissions so containers (coturn/nginx etc.) can write logs
|
||||
mkdir -p logs logs/nginx logs/backend logs/frontend logs/coturn 2>/dev/null || true
|
||||
chmod 777 -R logs 2>/dev/null || true
|
||||
log_info "Log directories prepared and permissions set: ./logs (mode 777)"
|
||||
|
||||
# Stop existing services
|
||||
if docker compose ps | grep -q "Up"; then
|
||||
log_info "Stopping existing services..."
|
||||
docker compose down
|
||||
fi
|
||||
|
||||
# Determine enabled services (Compose V2 requires --profile before the subcommand)
|
||||
local profiles=""
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
profiles="$profiles --profile nginx"
|
||||
fi
|
||||
if [[ "$WITH_TURN" == "true" ]]; then
|
||||
profiles="$profiles --profile turn"
|
||||
fi
|
||||
|
||||
# Build images (parallel first, fall back to serial on failure)
|
||||
log_info "Building Docker images..."
|
||||
set +e
|
||||
docker compose build --parallel
|
||||
local build_status=$?
|
||||
set -e
|
||||
if [[ $build_status -ne 0 ]]; then
|
||||
log_warning "Parallel build failed; falling back to serial build..."
|
||||
docker compose build
|
||||
fi
|
||||
|
||||
# Start services (--profile must precede up)
|
||||
log_info "Starting services..."
|
||||
# shellcheck disable=SC2086
|
||||
docker compose $profiles up -d
|
||||
|
||||
log_success "Services started"
|
||||
}
|
||||
|
||||
# Wait for services to be ready
|
||||
wait_for_services() {
|
||||
log_info "Waiting for services to be ready..."
|
||||
|
||||
local max_attempts=60
|
||||
local attempt=0
|
||||
local services_ready=false
|
||||
|
||||
while [[ $attempt -lt $max_attempts ]]; do
|
||||
local backend_ready=false
|
||||
local frontend_ready=false
|
||||
|
||||
# Check backend health
|
||||
if curl -f http://localhost:3001/health &> /dev/null; then
|
||||
backend_ready=true
|
||||
fi
|
||||
|
||||
# Check frontend health
|
||||
if curl -f http://localhost:3002/api/health &> /dev/null; then
|
||||
frontend_ready=true
|
||||
fi
|
||||
|
||||
if [[ "$backend_ready" == "true" ]] && [[ "$frontend_ready" == "true" ]]; then
|
||||
services_ready=true
|
||||
break
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$services_ready" == "true" ]]; then
|
||||
log_success "All services are ready"
|
||||
return 0
|
||||
else
|
||||
log_error "Service startup timed out"
|
||||
log_info "View service status: docker compose ps"
|
||||
log_info "View service logs: docker compose logs -f"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run post-deployment checks
|
||||
post_deployment_checks() {
|
||||
log_info "Running post-deployment checks..."
|
||||
|
||||
# Check container status
|
||||
log_info "Checking container status..."
|
||||
docker compose ps
|
||||
|
||||
# In full+nginx, add HTTPS health check (if domain defined)
|
||||
if [[ -f ".env" ]]; then
|
||||
local dep_mode="$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)"
|
||||
local dname="$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)"
|
||||
if [[ "$dep_mode" == "full" && -n "$dname" ]]; then
|
||||
log_info "Test: HTTPS health check https://$dname/api/health"
|
||||
if curl -fsS "https://$dname/api/health" >/dev/null; then
|
||||
log_success "HTTPS health check passed"
|
||||
else
|
||||
log_warning "HTTPS health check failed. If the certificate was just issued, wait a bit or run: bash docker/scripts/generate-config.sh --mode full --domain $dname --no-clean && docker compose exec -T nginx nginx -s reload"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run health-check tests
|
||||
if [[ -f "test-health-apis.sh" ]]; then
|
||||
log_info "Running health-check tests..."
|
||||
if bash test-health-apis.sh; then
|
||||
log_success "Health-check tests passed"
|
||||
else
|
||||
log_warning "Health-check tests failed, but services may still be working"
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Post-deployment checks complete"
|
||||
}
|
||||
|
||||
# Show deployment results
|
||||
show_deployment_info() {
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 PrivyDrop deployment complete!${NC}"
|
||||
echo ""
|
||||
|
||||
# Read configuration
|
||||
local local_ip=""
|
||||
local public_ip=""
|
||||
local frontend_port=""
|
||||
local backend_port=""
|
||||
local https_port=""
|
||||
local deployment_mode=""
|
||||
local network_mode=""
|
||||
local domain_name=""
|
||||
local turn_enabled_env=""
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
local_ip=$(grep "LOCAL_IP=" .env | cut -d'=' -f2)
|
||||
public_ip=$(grep "PUBLIC_IP=" .env | cut -d'=' -f2)
|
||||
frontend_port=$(grep "FRONTEND_PORT=" .env | cut -d'=' -f2)
|
||||
backend_port=$(grep "BACKEND_PORT=" .env | cut -d'=' -f2)
|
||||
https_port=$(grep "HTTPS_PORT=" .env | cut -d'=' -f2)
|
||||
deployment_mode=$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)
|
||||
network_mode=$(grep "NETWORK_MODE=" .env | cut -d'=' -f2)
|
||||
domain_name=$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)
|
||||
turn_enabled_env=$(grep "TURN_ENABLED=" .env | cut -d'=' -f2)
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}📋 Access Info:${NC}"
|
||||
|
||||
# Determine if public scenario (public/full)
|
||||
local is_public="false"
|
||||
if [[ "$deployment_mode" == "public" || "$deployment_mode" == "full" || "$network_mode" == "public" ]]; then
|
||||
is_public="true"
|
||||
fi
|
||||
|
||||
if [[ "$is_public" == "true" ]]; then
|
||||
# For public scenarios, prefer domain, then public IP
|
||||
if [[ -n "$domain_name" ]]; then
|
||||
if [[ "$WITH_NGINX" == "true" || "$deployment_mode" == "full" ]]; then
|
||||
echo " Public access: https://$domain_name"
|
||||
echo " API: https://$domain_name"
|
||||
else
|
||||
echo " Public access: http://$domain_name:${frontend_port:-3002}"
|
||||
echo " API: http://$domain_name:${backend_port:-3001}"
|
||||
fi
|
||||
elif [[ -n "$public_ip" ]]; then
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
echo " Public access: http://$public_ip"
|
||||
echo " API: http://$public_ip"
|
||||
else
|
||||
echo " Public access: http://$public_ip:${frontend_port:-3002}"
|
||||
echo " API: http://$public_ip:${backend_port:-3001}"
|
||||
fi
|
||||
else
|
||||
# Fallback: show LAN and localhost if public IP is unavailable
|
||||
echo " Frontend: http://localhost:${frontend_port:-3002}"
|
||||
echo " Backend API: http://localhost:${backend_port:-3001}"
|
||||
fi
|
||||
else
|
||||
# Private/basic: localhost + LAN
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
# When Nginx is enabled and frontend uses same-origin API, prefer the gateway as the primary entry
|
||||
echo " Frontend: http://localhost"
|
||||
echo " API: http://localhost"
|
||||
else
|
||||
echo " Frontend: http://localhost:${frontend_port:-3002}"
|
||||
echo " Backend API: http://localhost:${backend_port:-3001}"
|
||||
fi
|
||||
if [[ -n "$local_ip" ]] && [[ "$local_ip" != "127.0.0.1" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 LAN Access:${NC}"
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
echo " Frontend: http://$local_ip"
|
||||
echo " API: http://$local_ip"
|
||||
else
|
||||
echo " Frontend: http://$local_ip:${frontend_port:-3002}"
|
||||
echo " Backend API: http://$local_ip:${backend_port:-3001}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}🔀 Nginx Proxy:${NC}"
|
||||
if [[ -n "$domain_name" ]]; then
|
||||
echo " HTTP: http://$domain_name"
|
||||
if [[ -f "docker/ssl/server-cert.pem" ]]; then
|
||||
echo " HTTPS: https://$domain_name"
|
||||
fi
|
||||
elif [[ -n "$public_ip" ]]; then
|
||||
echo " HTTP: http://$public_ip"
|
||||
if [[ -f "docker/ssl/server-cert.pem" ]]; then
|
||||
# In non-domain cases, show HTTPS with explicit port (e.g., lan-tls uses 8443)
|
||||
if [[ -n "$https_port" && "$https_port" != "443" ]]; then
|
||||
echo " HTTPS: https://$public_ip:$https_port"
|
||||
else
|
||||
echo " HTTPS: https://$public_ip"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " HTTP: http://localhost"
|
||||
if [[ -f "docker/ssl/server-cert.pem" ]]; then
|
||||
# Show correct HTTPS endpoint based on configured port
|
||||
if [[ -n "$https_port" && "$https_port" != "443" ]]; then
|
||||
echo " HTTPS: https://localhost:$https_port"
|
||||
if [[ -n "$local_ip" && "$local_ip" != "127.0.0.1" ]]; then
|
||||
echo " HTTPS (LAN): https://$local_ip:$https_port"
|
||||
fi
|
||||
else
|
||||
echo " HTTPS: https://localhost"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}🔧 Management Commands:${NC}"
|
||||
echo " Status: docker compose ps"
|
||||
echo " Logs: docker compose logs -f [service]"
|
||||
echo " Restart: docker compose restart [service]"
|
||||
echo " Stop: docker compose down"
|
||||
echo " Full cleanup: $0 --clean"
|
||||
|
||||
if [[ -f "docker/ssl/ca-cert.pem" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}🔒 SSL Certificates:${NC}"
|
||||
echo " CA certificate: docker/ssl/ca-cert.pem"
|
||||
echo " To trust HTTPS, import the CA certificate into your browser"
|
||||
fi
|
||||
|
||||
if [[ "$WITH_TURN" == "true" || "$turn_enabled_env" == "true" ]]; then
|
||||
local turn_username=""
|
||||
local turn_realm=""
|
||||
if [[ -f ".env" ]]; then
|
||||
turn_username=$(grep "TURN_USERNAME=" .env | cut -d'=' -f2)
|
||||
turn_realm=$(grep "TURN_REALM=" .env | cut -d'=' -f2)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}🔄 TURN Server:${NC}"
|
||||
# Prefer domain for TURN info; otherwise show public IP
|
||||
if [[ -n "$domain_name" ]]; then
|
||||
echo " STUN: stun:${domain_name}:3478"
|
||||
echo " TURN (UDP): turn:${domain_name}:3478"
|
||||
echo " TURN (TLS): turns:turn.${domain_name}:443 (if 443 SNI split is configured)"
|
||||
elif [[ -n "$public_ip" ]]; then
|
||||
echo " STUN: stun:${public_ip}:3478"
|
||||
echo " TURN: turn:${public_ip}:3478"
|
||||
else
|
||||
echo " STUN: stun:${local_ip}:3478"
|
||||
echo " TURN: turn:${local_ip}:3478"
|
||||
fi
|
||||
echo " Username: ${turn_username:-privydrop}"
|
||||
echo " Password: (stored in .env)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 Tips:${NC}"
|
||||
echo " - First run may take several minutes to download and build images"
|
||||
echo " - If issues occur, check logs: docker compose logs -f"
|
||||
echo " - More help: $0 --help"
|
||||
echo ""
|
||||
|
||||
# Public scenario: for domain + HTTPS setup steps, see docs
|
||||
if [[ "$is_public" == "true" && -z "$domain_name" ]]; then
|
||||
echo -e "${BLUE}🌍 Domain + HTTPS guide:${NC} see docs/DEPLOYMENT_docker.md or docs/DEPLOYMENT_docker.zh-CN.md"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
echo -e "${BLUE}=== PrivyDrop Docker One-Click Deployment ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Parse command-line arguments
|
||||
parse_arguments "$@"
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies
|
||||
echo ""
|
||||
|
||||
# If only testing renewal, run and exit
|
||||
if [[ "$TEST_RENEWAL" == "true" ]]; then
|
||||
test_renewal && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
# Clean mode
|
||||
clean_deployment
|
||||
# If only cleaning (no other args), exit early to skip env detection
|
||||
if [[ "$CLEAN_MODE" == "true" && -z "$DEPLOYMENT_MODE" && "$WITH_NGINX" == "false" && "$WITH_TURN" == "false" && -z "$DOMAIN_NAME" ]]; then
|
||||
log_success "Cleanup complete (clean-only mode). Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Environment setup
|
||||
setup_environment
|
||||
echo ""
|
||||
|
||||
# Deploy services
|
||||
deploy_services
|
||||
echo ""
|
||||
|
||||
# If full + nginx, automatically issue certs and enable 443
|
||||
provision_letsencrypt_cert || true
|
||||
# Ensure TURN is running (when requested with --with-turn)
|
||||
ensure_turn_running || true
|
||||
|
||||
# Wait for services to be ready
|
||||
if wait_for_services; then
|
||||
echo ""
|
||||
post_deployment_checks
|
||||
show_deployment_info
|
||||
else
|
||||
log_error "Deployment failed. Please check logs: docker compose logs"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Trap interrupt signals
|
||||
trap 'log_warning "Deployment interrupted"; exit 1' INT TERM
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -0,0 +1,144 @@
|
||||
services:
|
||||
# Redis cache service
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: privydrop-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- privydrop-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# Backend signaling service
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- HTTP_PROXY=${HTTP_PROXY}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY}
|
||||
- NO_PROXY=${NO_PROXY}
|
||||
container_name: privydrop-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- BACKEND_PORT=3001
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3001}:3001"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- privydrop-network
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "health-check.js"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Frontend app
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- HTTP_PROXY=${HTTP_PROXY}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY}
|
||||
- NO_PROXY=${NO_PROXY}
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
- NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
|
||||
- NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
|
||||
- NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
|
||||
- NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED:-true}
|
||||
container_name: privydrop-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001}
|
||||
- BACKEND_INTERNAL_URL=${BACKEND_INTERNAL_URL:-http://backend:3001}
|
||||
- NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED:-true}
|
||||
- PORT=3002
|
||||
- HOSTNAME=0.0.0.0
|
||||
- NODE_EXTRA_CA_CERTS=/opt/privydrop/ssl/ca-cert.pem
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3002}:3002"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- privydrop-network
|
||||
volumes:
|
||||
- ./docker/ssl:/opt/privydrop/ssl:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "health-check.js"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
|
||||
# Nginx reverse proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: privydrop-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:${DOCKER_HTTPS_CONTAINER_PORT:-443}"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./docker/letsencrypt-www:/var/www/certbot:ro
|
||||
- ./docker/ssl:/etc/nginx/ssl:ro
|
||||
- ./logs/nginx:/var/log/nginx
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- privydrop-network
|
||||
profiles:
|
||||
- nginx
|
||||
|
||||
# TURN/STUN server (optional, for NAT traversal)
|
||||
coturn:
|
||||
image: coturn/coturn:4.6.2
|
||||
container_name: privydrop-coturn
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3478:3478/tcp"
|
||||
- "3478:3478/udp"
|
||||
- "5349:5349/tcp"
|
||||
- "5349:5349/udp"
|
||||
- "${TURN_MIN_PORT:-49152}-${TURN_MAX_PORT:-49252}:${TURN_MIN_PORT:-49152}-${TURN_MAX_PORT:-49252}/udp"
|
||||
volumes:
|
||||
- ./docker/coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||
- ./docker/ssl:/etc/ssl/certs:ro
|
||||
- ./logs/coturn:/var/log
|
||||
networks:
|
||||
- privydrop-network
|
||||
profiles:
|
||||
- turn
|
||||
command: ["-c", "/etc/coturn/turnserver.conf"]
|
||||
|
||||
networks:
|
||||
privydrop-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
driver: local
|
||||
Executable
+487
@@ -0,0 +1,487 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Global variables
|
||||
NETWORK_MODE=""
|
||||
LOCAL_IP=""
|
||||
PUBLIC_IP=""
|
||||
DEPLOYMENT_MODE="basic"
|
||||
FORCED_MODE=""
|
||||
LOCAL_IP_OVERRIDE=""
|
||||
|
||||
declare -a IP_CANDIDATES=()
|
||||
declare -A __SEEN_IPS=()
|
||||
|
||||
add_ip_candidate() {
|
||||
local ip="$1"
|
||||
[[ -z "$ip" ]] && return
|
||||
[[ "$ip" == "127."* ]] && return
|
||||
[[ "$ip" == "0.0.0.0" ]] && return
|
||||
if [[ -z "${__SEEN_IPS[$ip]}" ]]; then
|
||||
IP_CANDIDATES+=("$ip")
|
||||
__SEEN_IPS[$ip]=1
|
||||
fi
|
||||
}
|
||||
|
||||
is_rfc1918_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_cgnat_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
100.*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_reserved_benchmark_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
198.18.*|198.19.*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_link_local_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
169.254.*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_routable_public_ip() {
|
||||
local ip="$1"
|
||||
if [[ -z "$ip" ]]; then
|
||||
return 1
|
||||
fi
|
||||
if is_rfc1918_ip "$ip"; then
|
||||
return 1
|
||||
fi
|
||||
if is_cgnat_ip "$ip"; then
|
||||
return 1
|
||||
fi
|
||||
if is_reserved_benchmark_ip "$ip"; then
|
||||
return 1
|
||||
fi
|
||||
case "$ip" in
|
||||
127.*|169.254.*)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
collect_ip_candidates() {
|
||||
IP_CANDIDATES=()
|
||||
unset __SEEN_IPS
|
||||
declare -A __SEEN_IPS=()
|
||||
|
||||
if command -v hostname >/dev/null 2>&1; then
|
||||
local host_ips
|
||||
host_ips=$(hostname -I 2>/dev/null || true)
|
||||
for ip in $host_ips; do
|
||||
add_ip_candidate "$ip"
|
||||
done
|
||||
fi
|
||||
|
||||
if command -v ip >/dev/null 2>&1; then
|
||||
while IFS= read -r ip; do
|
||||
add_ip_candidate "$ip"
|
||||
done < <(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
|
||||
fi
|
||||
|
||||
if command -v ifconfig >/dev/null 2>&1; then
|
||||
while IFS= read -r ip; do
|
||||
add_ip_candidate "$ip"
|
||||
done < <(ifconfig 2>/dev/null | awk '/inet / {print $2}' | grep -E '^[0-9]+(\.[0-9]+){3}$')
|
||||
fi
|
||||
|
||||
if command -v ip >/dev/null 2>&1; then
|
||||
local route_ip
|
||||
route_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
|
||||
add_ip_candidate "$route_ip"
|
||||
fi
|
||||
|
||||
if [[ ${#IP_CANDIDATES[@]} -eq 0 ]]; then
|
||||
local fallback
|
||||
fallback=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
add_ip_candidate "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_local_ip() {
|
||||
if [[ -n "$LOCAL_IP_OVERRIDE" ]]; then
|
||||
LOCAL_IP="$LOCAL_IP_OVERRIDE"
|
||||
return
|
||||
fi
|
||||
|
||||
collect_ip_candidates
|
||||
|
||||
if [[ ${#IP_CANDIDATES[@]} -eq 0 ]]; then
|
||||
LOCAL_IP=""
|
||||
return
|
||||
fi
|
||||
|
||||
local ip
|
||||
for ip in "${IP_CANDIDATES[@]}"; do
|
||||
if is_rfc1918_ip "$ip"; then
|
||||
LOCAL_IP="$ip"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
for ip in "${IP_CANDIDATES[@]}"; do
|
||||
if is_cgnat_ip "$ip"; then
|
||||
LOCAL_IP="$ip"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
for ip in "${IP_CANDIDATES[@]}"; do
|
||||
if is_reserved_benchmark_ip "$ip"; then
|
||||
continue
|
||||
fi
|
||||
if is_link_local_ip "$ip"; then
|
||||
continue
|
||||
fi
|
||||
LOCAL_IP="$ip"
|
||||
return
|
||||
done
|
||||
|
||||
LOCAL_IP="${IP_CANDIDATES[0]}"
|
||||
}
|
||||
|
||||
# Logging helpers
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# Detect network environment
|
||||
detect_network_environment() {
|
||||
log_info "Detecting network environment..."
|
||||
|
||||
resolve_local_ip
|
||||
|
||||
if [[ -z "$LOCAL_IP" ]]; then
|
||||
LOCAL_IP="127.0.0.1"
|
||||
log_warning "Unable to detect host IP; using default: $LOCAL_IP"
|
||||
fi
|
||||
|
||||
if [[ "$FORCED_MODE" == "private" ]]; then
|
||||
NETWORK_MODE="private"
|
||||
PUBLIC_IP=""
|
||||
log_info "Network mode set via parameters: $NETWORK_MODE"
|
||||
echo " Local IP: $LOCAL_IP"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local mode_guess="private"
|
||||
local printed_prompt_info="false"
|
||||
PUBLIC_IP=""
|
||||
|
||||
if curl -s --connect-timeout 5 --max-time 10 ifconfig.me > /dev/null 2>&1; then
|
||||
PUBLIC_IP=$(curl -s --connect-timeout 5 --max-time 10 ifconfig.me 2>/dev/null || echo "")
|
||||
if [[ -n "$PUBLIC_IP" ]]; then
|
||||
if is_routable_public_ip "$PUBLIC_IP"; then
|
||||
mode_guess="public"
|
||||
else
|
||||
log_warning "Public IP is test/reserved range; treating as private"
|
||||
fi
|
||||
else
|
||||
log_warning "Public connectivity unstable; treating as private"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$FORCED_MODE" ]]; then
|
||||
if [[ "$mode_guess" == "public" ]]; then
|
||||
NETWORK_MODE="public"
|
||||
else
|
||||
NETWORK_MODE="private"
|
||||
fi
|
||||
else
|
||||
NETWORK_MODE="$FORCED_MODE"
|
||||
if [[ "$FORCED_MODE" == "public" && -z "$PUBLIC_IP" ]]; then
|
||||
log_warning "Could not detect public IP; continuing as public mode. Please verify network config"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$NETWORK_MODE" != "public" ]]; then
|
||||
PUBLIC_IP=""
|
||||
fi
|
||||
|
||||
if [[ "$FORCED_MODE" == "public" ]]; then
|
||||
log_info "Network mode set via parameters: $NETWORK_MODE"
|
||||
elif [[ "$NETWORK_MODE" == "public" ]]; then
|
||||
log_success "Public network detected"
|
||||
else
|
||||
log_success "Private network detected"
|
||||
fi
|
||||
|
||||
if [[ "$printed_prompt_info" == "false" ]]; then
|
||||
echo " Local IP: $LOCAL_IP"
|
||||
if [[ "$NETWORK_MODE" == "public" && -n "$PUBLIC_IP" ]]; then
|
||||
echo " Public IP: $PUBLIC_IP"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Check system resources
|
||||
check_system_resources() {
|
||||
log_info "Checking system resources..."
|
||||
|
||||
local warnings=0
|
||||
|
||||
# Check memory
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
TOTAL_MEM=$(free -m | awk 'NR==2{print $2}')
|
||||
if [[ $TOTAL_MEM -lt 512 ]]; then
|
||||
log_error "Insufficient memory: ${TOTAL_MEM}MB (512MB+ recommended)"
|
||||
return 1
|
||||
elif [[ $TOTAL_MEM -lt 1024 ]]; then
|
||||
log_warning "Low memory: ${TOTAL_MEM}MB (1GB+ recommended)"
|
||||
warnings=$((warnings + 1))
|
||||
else
|
||||
log_success "Memory OK: ${TOTAL_MEM}MB"
|
||||
fi
|
||||
else
|
||||
log_warning "Unable to read memory usage"
|
||||
warnings=$((warnings + 1))
|
||||
fi
|
||||
|
||||
# Check disk usage
|
||||
DISK_USAGE=$(df -h / | awk 'NR==2{print $5}' | sed 's/%//')
|
||||
if [[ $DISK_USAGE -gt 95 ]]; then
|
||||
log_error "Insufficient disk space: ${DISK_USAGE}% used"
|
||||
return 1
|
||||
elif [[ $DISK_USAGE -gt 80 ]]; then
|
||||
log_warning "Disk space tight: ${DISK_USAGE}% used"
|
||||
warnings=$((warnings + 1))
|
||||
else
|
||||
log_success "Disk space OK: ${DISK_USAGE}% used"
|
||||
fi
|
||||
|
||||
# Check available disk space
|
||||
AVAILABLE_SPACE=$(df -BG / | awk 'NR==2{print $4}' | sed 's/G//')
|
||||
if [[ $AVAILABLE_SPACE -lt 2 ]]; then
|
||||
log_error "Not enough free disk space: ${AVAILABLE_SPACE}GB (2GB+ recommended)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $warnings -gt 0 ]]; then
|
||||
log_warning "System resource check passed with $warnings warning(s)"
|
||||
else
|
||||
log_success "System resource check passed"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Validate Docker environment
|
||||
verify_docker_installation() {
|
||||
log_info "Checking Docker environment..."
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker is not installed"
|
||||
echo "Please install Docker: https://docs.docker.com/get-docker/"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
log_error "Docker Compose is not installed"
|
||||
echo "Please install Docker Compose: https://docs.docker.com/compose/install/"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check Docker service status
|
||||
if ! docker info &> /dev/null; then
|
||||
log_error "Docker service is not running"
|
||||
echo "Please start the Docker service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check Docker version
|
||||
DOCKER_VERSION=$(docker --version | grep -oE '[0-9]+\.[0-9]+' | head -1)
|
||||
log_success "Docker version: $DOCKER_VERSION"
|
||||
|
||||
# Check Docker Compose version
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_VERSION=$(docker-compose --version | grep -oE '[0-9]+\.[0-9]+' | head -1)
|
||||
log_success "Docker Compose version: $COMPOSE_VERSION"
|
||||
else
|
||||
COMPOSE_VERSION=$(docker compose version --short 2>/dev/null || echo "built-in")
|
||||
log_success "Docker Compose version: $COMPOSE_VERSION"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check port usage
|
||||
check_port_availability() {
|
||||
local ports="$1"
|
||||
log_info "Checking port usage..."
|
||||
|
||||
local occupied_ports=()
|
||||
|
||||
IFS=',' read -ra PORT_ARRAY <<< "$ports"
|
||||
for port in "${PORT_ARRAY[@]}"; do
|
||||
port=$(echo "$port" | xargs) # Trim spaces
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if ss -tuln | grep -q ":$port "; then
|
||||
occupied_ports+=("$port")
|
||||
fi
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
if netstat -tuln 2>/dev/null | grep -q ":$port "; then
|
||||
occupied_ports+=("$port")
|
||||
fi
|
||||
else
|
||||
log_warning "Unable to check port usage (missing ss and netstat)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#occupied_ports[@]} -gt 0 ]]; then
|
||||
log_warning "Ports in use: ${occupied_ports[*]}"
|
||||
log_info "Change ports in .env, or run './deploy.sh --clean' / 'docker-compose down' to clean old containers"
|
||||
else
|
||||
log_success "All ports available"
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect deployment mode
|
||||
detect_deployment_mode() {
|
||||
log_info "Determining deployment mode..."
|
||||
|
||||
if [[ "$NETWORK_MODE" == "public" ]] && [[ -n "$DOMAIN_NAME" ]]; then
|
||||
DEPLOYMENT_MODE="full"
|
||||
log_success "Deployment mode: full (HTTPS + TURN server)"
|
||||
elif [[ "$NETWORK_MODE" == "public" ]]; then
|
||||
DEPLOYMENT_MODE="public"
|
||||
log_success "Deployment mode: public (HTTP + TURN)"
|
||||
else
|
||||
DEPLOYMENT_MODE="basic"
|
||||
log_success "Deployment mode: basic (intranet HTTP)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
echo -e "${BLUE}=== PrivyDrop Docker Environment Check ===${NC}\n"
|
||||
|
||||
# Parse command-line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--domain)
|
||||
DOMAIN_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mode)
|
||||
DEPLOYMENT_MODE="$2"
|
||||
case "$2" in
|
||||
private|basic)
|
||||
FORCED_MODE="private"
|
||||
;;
|
||||
public|full)
|
||||
FORCED_MODE="public"
|
||||
;;
|
||||
*)
|
||||
FORCED_MODE=""
|
||||
;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--local-ip)
|
||||
LOCAL_IP_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Run checks
|
||||
detect_network_environment
|
||||
echo ""
|
||||
|
||||
if ! check_system_resources; then
|
||||
log_error "System resource check failed; resolve resource issues and retry"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if ! verify_docker_installation; then
|
||||
log_error "Docker environment check failed; please install and start Docker"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
check_port_availability "80,443,3002,3001,3478,5349,6379"
|
||||
echo ""
|
||||
|
||||
detect_deployment_mode
|
||||
echo ""
|
||||
|
||||
log_success "Environment check complete!"
|
||||
echo -e "${BLUE}Results:${NC}"
|
||||
echo " Network mode: $NETWORK_MODE"
|
||||
echo " Local IP: $LOCAL_IP"
|
||||
[[ -n "$PUBLIC_IP" ]] && echo " Public IP: $PUBLIC_IP"
|
||||
echo " Deployment mode: $DEPLOYMENT_MODE"
|
||||
|
||||
# Export env vars for other scripts
|
||||
export NETWORK_MODE
|
||||
export LOCAL_IP
|
||||
export PUBLIC_IP
|
||||
export DEPLOYMENT_MODE
|
||||
export DOMAIN_NAME
|
||||
export LOCAL_IP_OVERRIDE
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# If the script is executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
Executable
+1086
File diff suppressed because it is too large
Load Diff
Executable
+420
@@ -0,0 +1,420 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PrivyDrop Docker deployment test script
|
||||
# Validate deployment integrity and functionality
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Test result counters
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
TOTAL_TESTS=0
|
||||
|
||||
# Logging helpers
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
# Test functions
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
local test_command="$2"
|
||||
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
log_info "Test: $test_name"
|
||||
|
||||
if eval "$test_command" >/dev/null 2>&1; then
|
||||
log_success "$test_name"
|
||||
return 0
|
||||
else
|
||||
log_error "$test_name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Docker environment tests
|
||||
test_docker_environment() {
|
||||
echo -e "${BLUE}=== Docker Environment Tests ===${NC}"
|
||||
|
||||
run_test "Docker installed" "command -v docker"
|
||||
run_test "Docker daemon running" "docker info"
|
||||
run_test "Docker Compose available" "docker-compose --version || docker compose version"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Container status tests
|
||||
test_container_status() {
|
||||
echo -e "${BLUE}=== Container Status Tests ===${NC}"
|
||||
|
||||
# Check if containers exist and are running
|
||||
local containers=("privydrop-redis" "privydrop-backend" "privydrop-frontend")
|
||||
|
||||
for container in "${containers[@]}"; do
|
||||
run_test "Container $container is running" "docker ps | grep -q $container"
|
||||
done
|
||||
|
||||
# Check container health
|
||||
for container in "${containers[@]}"; do
|
||||
if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "$container.*healthy"; then
|
||||
log_success "Container $container health OK"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Container $container health unknown or unhealthy"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Network connectivity tests
|
||||
test_network_connectivity() {
|
||||
echo -e "${BLUE}=== Network Connectivity Tests ===${NC}"
|
||||
|
||||
# Test port connectivity
|
||||
local ports=("3002:Frontend" "3001:Backend" "6379:Redis")
|
||||
|
||||
for port_info in "${ports[@]}"; do
|
||||
local port=$(echo "$port_info" | cut -d':' -f1)
|
||||
local service=$(echo "$port_info" | cut -d':' -f2)
|
||||
|
||||
run_test "$service port $port reachable" "nc -z localhost $port"
|
||||
done
|
||||
|
||||
# Test inter-container networking
|
||||
run_test "Backend can connect to Redis" "docker-compose exec -T backend sh -c 'nc -z redis 6379'"
|
||||
run_test "Frontend can reach backend" "curl -f http://localhost:3001/health"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# API functionality tests
|
||||
test_api_functionality() {
|
||||
echo -e "${BLUE}=== API Functionality Tests ===${NC}"
|
||||
|
||||
# Health check APIs
|
||||
run_test "Backend health check API" "curl -f http://localhost:3001/health"
|
||||
run_test "Frontend health check API" "curl -f http://localhost:3002/api/health"
|
||||
|
||||
# Backend detailed health check
|
||||
if curl -f http://localhost:3001/health/detailed >/dev/null 2>&1; then
|
||||
local redis_status=$(curl -s http://localhost:3001/health/detailed | jq -r '.dependencies.redis.status' 2>/dev/null)
|
||||
if [[ "$redis_status" == "connected" ]]; then
|
||||
log_success "Redis connection OK"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Redis connection issue"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
else
|
||||
log_error "Detailed health check API unavailable"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Application API tests
|
||||
run_test "Get room API" "curl -f http://localhost:3001/api/get_room"
|
||||
run_test "Create room API" "curl -f -X POST -H 'Content-Type: application/json' -d '{\"roomId\":\"test123\"}' http://localhost:3001/api/create_room"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# WebRTC functionality tests
|
||||
test_webrtc_functionality() {
|
||||
echo -e "${BLUE}=== WebRTC Functionality Tests ===${NC}"
|
||||
|
||||
# Test frontend page load
|
||||
if curl -f http://localhost:3002 >/dev/null 2>&1; then
|
||||
log_success "Frontend page reachable"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Frontend page not reachable"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Test Socket.IO connection (basic)
|
||||
if curl -f http://localhost:3001/socket.io/socket.io.js >/dev/null 2>&1; then
|
||||
log_success "Socket.IO client script reachable"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Socket.IO client script not reachable"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Performance tests
|
||||
test_performance() {
|
||||
echo -e "${BLUE}=== Performance Tests ===${NC}"
|
||||
|
||||
# Memory usage test
|
||||
local backend_memory=$(docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" | grep privydrop-backend | awk '{print $2}' | cut -d'/' -f1)
|
||||
local frontend_memory=$(docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" | grep privydrop-frontend | awk '{print $2}' | cut -d'/' -f1)
|
||||
|
||||
if [[ -n "$backend_memory" ]]; then
|
||||
log_info "Backend memory usage: $backend_memory"
|
||||
fi
|
||||
|
||||
if [[ -n "$frontend_memory" ]]; then
|
||||
log_info "Frontend memory usage: $frontend_memory"
|
||||
fi
|
||||
|
||||
# Response time test
|
||||
local response_time=$(curl -o /dev/null -s -w '%{time_total}' http://localhost:3001/health)
|
||||
if (( $(echo "$response_time < 1.0" | bc -l) )); then
|
||||
log_success "API response time OK: ${response_time}s"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "API response time slow: ${response_time}s"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Security tests
|
||||
test_security() {
|
||||
echo -e "${BLUE}=== Security Tests ===${NC}"
|
||||
|
||||
# Check container users
|
||||
local backend_user=$(docker-compose exec -T backend whoami 2>/dev/null || echo "unknown")
|
||||
local frontend_user=$(docker-compose exec -T frontend whoami 2>/dev/null || echo "unknown")
|
||||
|
||||
if [[ "$backend_user" != "root" ]]; then
|
||||
log_success "Backend container uses non-root user: $backend_user"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Backend container runs as root"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
if [[ "$frontend_user" != "root" ]]; then
|
||||
log_success "Frontend container uses non-root user: $frontend_user"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Frontend container runs as root"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Check for sensitive info leakage
|
||||
if curl -s http://localhost:3001/health/detailed | grep -q "password\|secret\|key" >/dev/null 2>&1; then
|
||||
log_warning "Health check API may leak sensitive info"
|
||||
else
|
||||
log_success "Health check API does not leak sensitive info"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Logging tests
|
||||
test_logging() {
|
||||
echo -e "${BLUE}=== Logging Tests ===${NC}"
|
||||
|
||||
# Check log directories
|
||||
if [[ -d "logs" ]]; then
|
||||
log_success "Log directory exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Log directory does not exist"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Check log files
|
||||
local log_files=("logs/backend" "logs/frontend")
|
||||
for log_dir in "${log_files[@]}"; do
|
||||
if [[ -d "$log_dir" ]]; then
|
||||
log_success "Log directory $log_dir exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_info "Log directory $log_dir not found (may be normal)"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Configuration file tests
|
||||
test_configuration() {
|
||||
echo -e "${BLUE}=== Configuration File Tests ===${NC}"
|
||||
|
||||
# Check env file
|
||||
if [[ -f ".env" ]]; then
|
||||
log_success ".env file exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
|
||||
# Check key configuration entries
|
||||
local required_vars=("LOCAL_IP" "CORS_ORIGIN" "NEXT_PUBLIC_API_URL")
|
||||
for var in "${required_vars[@]}"; do
|
||||
if grep -q "^$var=" .env; then
|
||||
log_success "Config $var is set"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Config $var is not set"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
done
|
||||
else
|
||||
log_error ".env file not found"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Check Docker Compose file
|
||||
if [[ -f "docker-compose.yml" ]]; then
|
||||
log_success "docker-compose.yml exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "docker-compose.yml not found"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Cleanup tests
|
||||
test_cleanup() {
|
||||
echo -e "${BLUE}=== Cleanup Tests ===${NC}"
|
||||
|
||||
# Verify cleanup commands work
|
||||
if [[ -f "deploy.sh" ]]; then
|
||||
log_success "Deployment script exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
|
||||
# Test help command
|
||||
if bash deploy.sh --help >/dev/null 2>&1; then
|
||||
log_success "Deployment script help works"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Deployment script help fails"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
else
|
||||
log_error "Deployment script not found"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 2))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Generate test report
|
||||
generate_report() {
|
||||
echo -e "${BLUE}=== Test Report ===${NC}"
|
||||
echo ""
|
||||
|
||||
echo "📊 Test stats:"
|
||||
echo " Total tests: $TOTAL_TESTS"
|
||||
echo -e " Passed: ${GREEN}$TESTS_PASSED${NC}"
|
||||
echo -e " Failed: ${RED}$TESTS_FAILED${NC}"
|
||||
|
||||
local success_rate=$((TESTS_PASSED * 100 / TOTAL_TESTS))
|
||||
echo " Success rate: $success_rate%"
|
||||
|
||||
echo ""
|
||||
echo "📋 System info:"
|
||||
echo " Docker version: $(docker --version)"
|
||||
echo " Docker Compose version: $(docker-compose --version 2>/dev/null || docker compose version 2>/dev/null || echo 'unknown')"
|
||||
echo " OS: $(uname -s) $(uname -r)"
|
||||
echo " Test time: $(date)"
|
||||
|
||||
echo ""
|
||||
if [[ $TESTS_FAILED -eq 0 ]]; then
|
||||
echo -e "${GREEN}🎉 All tests passed! PrivyDrop deployment successful!${NC}"
|
||||
echo ""
|
||||
echo "🔗 Access links:"
|
||||
echo " Frontend: http://localhost:3002"
|
||||
echo " Backend API: http://localhost:3001"
|
||||
|
||||
# Show LAN access addresses
|
||||
if [[ -f ".env" ]]; then
|
||||
local local_ip=$(grep "LOCAL_IP=" .env | cut -d'=' -f2)
|
||||
if [[ -n "$local_ip" && "$local_ip" != "127.0.0.1" ]]; then
|
||||
echo ""
|
||||
echo "🌐 LAN access:"
|
||||
echo " Frontend: http://$local_ip:3002"
|
||||
echo " Backend API: http://$local_ip:3001"
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ $TESTS_FAILED test(s) failed${NC}"
|
||||
echo ""
|
||||
echo "🔧 Troubleshooting tips:"
|
||||
echo " 1. View container status: docker-compose ps"
|
||||
echo " 2. View container logs: docker-compose logs -f"
|
||||
echo " 3. Redeploy: bash deploy.sh"
|
||||
echo " 4. Clean and redeploy: bash deploy.sh --clean"
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
echo -e "${BLUE}=== PrivyDrop Docker Deployment Tests ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Check required tools
|
||||
local missing_tools=()
|
||||
for tool in curl jq bc nc; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing_tools+=("$tool")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
||||
log_warning "Missing test tools: ${missing_tools[*]}"
|
||||
log_info "Suggested install: sudo apt-get install curl jq bc netcat"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Run all tests
|
||||
test_docker_environment
|
||||
test_container_status
|
||||
test_network_connectivity
|
||||
test_api_functionality
|
||||
test_webrtc_functionality
|
||||
test_performance
|
||||
test_security
|
||||
test_logging
|
||||
test_configuration
|
||||
test_cleanup
|
||||
|
||||
# Generate report
|
||||
generate_report
|
||||
}
|
||||
|
||||
# Trap interrupt signals
|
||||
trap 'echo -e "\n${YELLOW}Tests interrupted${NC}"; exit 1' INT TERM
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -76,4 +76,9 @@ graph TD
|
||||
|
||||
- **Privacy First**: Core file data is never uploaded to the server. The server only acts as an "introducer" or "matchmaker."
|
||||
- **Frontend-Backend Separation**: Responsibilities are clearly separated. The frontend handles all user interaction and the complex logic of WebRTC; the backend provides lightweight, efficient signaling and room management services.
|
||||
- **Horizontal Scalability**: The backend is stateless (with state managed in Redis), which theoretically allows it to be scaled horizontally by adding more Node.js instances to handle a large volume of concurrent signaling requests.
|
||||
- **Horizontal Scalability**: The backend is stateless (with state managed in Redis), which theoretically allows it to be scaled horizontally by adding more Node.js instances to handle a large volume of concurrent signaling requests.
|
||||
|
||||
## 4. Runtime Session Model (Frontend)
|
||||
|
||||
- **SPA In-App Navigation Persistence**: The frontend is an SPA (App Router). Within the same browser tab, in-app navigation does not tear down the singleton app state (Zustand) nor the WebRTC connection service (webrtcService). Ongoing transfers continue, and selected/received content remains available.
|
||||
- **Boundary**: Page refresh, closing the tab, or opening in a new tab are not covered. If changing layout/SSR strategy, avoid cleaning the connection during layout unmount.
|
||||
|
||||
@@ -77,3 +77,8 @@ graph TD
|
||||
- **隐私优先**: 核心文件数据永不上传到服务器。服务器只承担“介绍人”的角色。
|
||||
- **前后端分离**: 前后端职责清晰。前端负责所有与用户交互和 WebRTC 的复杂逻辑;后端则提供轻量、高效的信令和房间管理服务。
|
||||
- **水平扩展**: 后端是无状态的(状态存储在 Redis 中),理论上可以通过增加 Node.js 实例来水平扩展,以应对大量并发信令请求。
|
||||
|
||||
## 四、运行时会话模型(前端)
|
||||
|
||||
- **SPA 站内导航保持**:前端为单页应用(App Router)。在同一标签页内进行站内跳转时,应用状态(Zustand 单例)与 WebRTC 连接服务(webrtcService 单例)不会被销毁,进行中的传输不中断,已选择/已接收内容保持。
|
||||
- **边界说明**:页面刷新、关闭标签页或在新标签页打开页面,不属于保持范围;如调整布局/SSR 策略,需避免在布局卸载阶段清理连接。
|
||||
|
||||
+110
-1
@@ -1,4 +1,8 @@
|
||||
# PrivyDrop Deployment Guide
|
||||
# PrivyDrop Deployment Guide (Bare-Metal)
|
||||
|
||||
> Audience & Scope: This guide targets developers/operators who prefer a non-container (bare-metal) setup.
|
||||
>
|
||||
> Recommended: Prefer the one-click Docker deployment for simplicity and robustness, including auto HTTPS and TURN. See [Docker Deployment Guide](./DEPLOYMENT_docker.md).
|
||||
|
||||
This guide provides comprehensive instructions for deploying the full-stack PrivyDrop application, including setting up Redis, a TURN server, the backend service, the frontend application, and configuring Nginx as a reverse proxy.
|
||||
|
||||
@@ -30,6 +34,7 @@ sudo bash backend/docker/env_install.sh
|
||||
```
|
||||
|
||||
This script will automatically install:
|
||||
|
||||
- **Node.js v20** - Runtime environment
|
||||
- **Redis Server** - Used for room management and caching
|
||||
- **Coturn** - TURN/STUN server (optional, for NAT traversal)
|
||||
@@ -38,6 +43,7 @@ This script will automatically install:
|
||||
- **Certbot** - SSL certificate management
|
||||
|
||||
After installation, you can verify the services:
|
||||
|
||||
```bash
|
||||
# Verify Node.js version
|
||||
node -v
|
||||
@@ -53,11 +59,13 @@ sudo systemctl status coturn
|
||||
```
|
||||
|
||||
**Configuration Notes:**
|
||||
|
||||
- **Redis Configuration:** Default listening on `127.0.0.1:6379`, ensure your backend `.env` file includes correct `REDIS_HOST` and `REDIS_PORT`
|
||||
- **TURN Service:** Optional configuration, PrivyDrop uses public STUN servers by default, only needed for extremely high NAT traversal requirements
|
||||
- **Nginx:** Script installs official version and verifies stream module support
|
||||
|
||||
**TURN Server Firewall Configuration (if configuring TURN service):**
|
||||
|
||||
```bash
|
||||
# Enable the Coturn service
|
||||
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
|
||||
@@ -68,6 +76,7 @@ sudo ufw reload
|
||||
```
|
||||
|
||||
The ports seen via `sudo ufw app info Turnserver` are as follows:
|
||||
|
||||
- `3478,3479,5349,5350,49152:65535/tcp`
|
||||
- `3478,3479,5349,5350,49152:65535/udp`
|
||||
|
||||
@@ -278,6 +287,106 @@ PM2 is a powerful process manager for Node.js. We will use it to run both backen
|
||||
- Restart services: `pm2 restart all` or specific service `pm2 restart signaling-server`
|
||||
- Stop services: `pm2 stop all` or specific service `pm2 stop privydrop-frontend`
|
||||
|
||||
### 4.7. Daily Incremental Update (Local Build + Remote Replace)
|
||||
|
||||
This section describes how to build locally and deploy only the built artifacts to the server. It is optimized for day-to-day releases: fast, low resource usage on the server, and easy to verify.
|
||||
|
||||
- Assumes you have completed the first-time deployment and can access the app in production.
|
||||
- The frontend runs in Next.js Standalone mode (configured in `ecosystem.config.js`), so the server does not need Next CLI or frontend dependencies installed.
|
||||
|
||||
0. Sync frontend production environment variables (important)
|
||||
|
||||
The local build reads variables from `frontend/.env.production` (e.g., `NEXT_PUBLIC_API_URL`, TURN settings, and build-time flags like `NEXT_IMAGE_UNOPTIMIZED`). To ensure the build matches production behavior, copy the production environment file from the server to your local machine before running `bash build-and-deploy.sh`.
|
||||
|
||||
- Example (sync from server to local):
|
||||
```bash
|
||||
# Assuming the server project root is /root/PrivyDrop
|
||||
scp root@<server>:/root/PrivyDrop/frontend/.env.production ./frontend/.env.production
|
||||
```
|
||||
- If the file does not exist on the server yet, create it from the example and keep it consistent with production:
|
||||
```bash
|
||||
cp frontend/.env_production_example frontend/.env.production
|
||||
# Fill in NEXT_PUBLIC_API_URL, TURN_*, NEXT_IMAGE_UNOPTIMIZED, etc.
|
||||
```
|
||||
- Note: `build-and-deploy.sh` does not auto-create or overwrite `frontend/.env.production`. Make sure it exists locally and matches production; otherwise, the built behavior may differ from the server (e.g., image optimization toggles).
|
||||
|
||||
1. Prepare deployment configuration
|
||||
|
||||
- From the project root:
|
||||
```bash
|
||||
cp deploy.config.example deploy.config
|
||||
```
|
||||
- Edit `deploy.config` with at least:
|
||||
```bash
|
||||
DEPLOY_SERVER="<your-server-ip-or-domain>"
|
||||
DEPLOY_USER="root" # Recommended: use ssh root for simplicity
|
||||
DEPLOY_PATH="/root/PrivyDrop" # Project root on the server
|
||||
# Optional: SSH_PORT, SSH_KEY_PATH
|
||||
```
|
||||
- Security notes: Use SSH key authentication, restrict source IPs, and enforce firewall rules in production.
|
||||
|
||||
2. Build locally and deploy
|
||||
|
||||
- From the project root:
|
||||
```bash
|
||||
bash build-and-deploy.sh
|
||||
```
|
||||
- When an existing package (out.zip) is detected, the script lets you choose:
|
||||
- 1. Deploy existing package
|
||||
- 2. Rebuild and deploy
|
||||
- Script flow (summary):
|
||||
- Build frontend and backend locally
|
||||
- Package artifacts into `out.zip`
|
||||
- Upload to server path `/tmp/out.zip`
|
||||
- Server-side backup to `/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`
|
||||
- Unzip and replace:
|
||||
- Frontend: `frontend/.next` (includes `.next/standalone` and `.next/static`)
|
||||
- Frontend static assets: `frontend/public`
|
||||
- Frontend content: `frontend/content` (for blog file reads)
|
||||
- Backend: `backend/dist`
|
||||
- Restart using `pm2 start ecosystem.config.js`
|
||||
|
||||
3. Post-deployment verification
|
||||
|
||||
- Check process status on the server:
|
||||
```bash
|
||||
ssh root@<server> 'sudo pm2 status'
|
||||
```
|
||||
- Compare frontend BUILD_ID (optional):
|
||||
```bash
|
||||
ssh root@<server> 'cat /root/PrivyDrop/frontend/.next/BUILD_ID'
|
||||
```
|
||||
- Force refresh the browser or use an incognito window to confirm the new version.
|
||||
|
||||
4. Backups and manual rollback
|
||||
|
||||
- Each deployment creates a structured backup on the server at `/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`:
|
||||
- Frontend: `frontend/.next`
|
||||
- Backend: `backend/dist`
|
||||
- To rollback manually (example):
|
||||
|
||||
```bash
|
||||
# Stop PM2
|
||||
sudo pm2 stop all && sudo pm2 delete all
|
||||
|
||||
# Choose a backup directory, e.g. /tmp/privydrop_backup/20241024_235959
|
||||
export DEPLOY_PATH=/root/PrivyDrop
|
||||
export BACKUP=/tmp/privydrop_backup/20241024_235959
|
||||
|
||||
# Restore frontend and backend build artifacts
|
||||
rm -rf "$DEPLOY_PATH/frontend/.next" "$DEPLOY_PATH/backend/dist"
|
||||
cp -a "$BACKUP/frontend/.next" "$DEPLOY_PATH/frontend/.next"
|
||||
cp -a "$BACKUP/backend/dist" "$DEPLOY_PATH/backend/dist"
|
||||
|
||||
# Restart PM2
|
||||
sudo pm2 start ecosystem.config.js
|
||||
```
|
||||
|
||||
5. Common issues
|
||||
- Page still shows the old version: clear browser cache/force refresh; compare BUILD_ID; check Nginx/CDN caching.
|
||||
- Blog posts not loading: ensure `frontend/content/blog` exists on the server and the PM2 frontend process `cwd` is `./frontend`.
|
||||
- `out.zip not found`: choose “Rebuild and deploy” to create a new package.
|
||||
|
||||
## 5. Troubleshooting
|
||||
|
||||
- **Connection Issues:** Check firewall settings, Nginx proxy configurations, `CORS_ORIGIN` settings, and ensure all PM2 processes are running.
|
||||
|
||||
+116
-6
@@ -1,4 +1,8 @@
|
||||
# Privydrop 部署指南
|
||||
# Privydrop 部署指南(裸机部署)
|
||||
|
||||
> 说明与定位:本指南面向具备 Linux 运维能力的开发者,介绍“裸机(非容器)”部署方式。
|
||||
>
|
||||
> 推荐方案:优先使用“一键 Docker 部署”,更简单、更稳健,支持自动签发/续期证书与 TURN。详见 [Docker 部署指南](./DEPLOYMENT_docker.zh-CN.md)。
|
||||
|
||||
本指南提供部署 Privydrop 全栈应用的全面说明,包括设置 Redis、TURN 服务器、后端服务、前端应用以及配置 Nginx 作为反向代理。
|
||||
|
||||
@@ -30,14 +34,16 @@ sudo bash backend/docker/env_install.sh
|
||||
```
|
||||
|
||||
该脚本将自动安装:
|
||||
|
||||
- **Node.js v20** - 运行环境
|
||||
- **Redis Server** - 用于房间管理和缓存
|
||||
- **Coturn** - TURN/STUN 服务器(可选,用于NAT穿透)
|
||||
- **Redis Server** - 用于房间管理和缓存
|
||||
- **Coturn** - TURN/STUN 服务器(可选,用于 NAT 穿透)
|
||||
- **Nginx** - Web 服务器和反向代理(使用官方仓库)
|
||||
- **PM2** - Node.js 进程管理器
|
||||
- **Certbot** - SSL 证书管理
|
||||
|
||||
安装完成后,可以验证各服务状态:
|
||||
|
||||
```bash
|
||||
# 验证 Node.js 版本
|
||||
node -v
|
||||
@@ -53,11 +59,13 @@ sudo systemctl status coturn
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- **Redis配置:** 默认监听 `127.0.0.1:6379`,请确保后端 `.env` 文件中包含正确的 `REDIS_HOST` 和 `REDIS_PORT`
|
||||
- **TURN服务:** 为可选配置,Privydrop 默认使用公共 STUN 服务器,只有对 NAT 穿透有极高要求时才需要配置
|
||||
|
||||
- **Redis 配置:** 默认监听 `127.0.0.1:6379`,请确保后端 `.env` 文件中包含正确的 `REDIS_HOST` 和 `REDIS_PORT`
|
||||
- **TURN 服务:** 为可选配置,Privydrop 默认使用公共 STUN 服务器,只有对 NAT 穿透有极高要求时才需要配置
|
||||
- **Nginx:** 脚本安装官方版本并验证 stream 模块支持
|
||||
|
||||
**TURN服务器防火墙配置(如果需要配置TURN服务):**
|
||||
**TURN 服务器防火墙配置(如果需要配置 TURN 服务):**
|
||||
|
||||
```bash
|
||||
# 启用 Coturn 服务
|
||||
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
|
||||
@@ -68,6 +76,7 @@ sudo ufw reload
|
||||
```
|
||||
|
||||
通过 `sudo ufw app info Turnserver` 看到的端口如下:
|
||||
|
||||
- `3478,3479,5349,5350,49152:65535/tcp`
|
||||
- `3478,3479,5349,5350,49152:65535/udp`
|
||||
|
||||
@@ -277,6 +286,107 @@ PM2 是一个强大的 Node.js 进程管理器,我们将用它来运行后端
|
||||
- 重启服务: `pm2 restart all` 或指定服务 `pm2 restart signaling-server`
|
||||
- 停止服务: `pm2 stop all` 或指定服务 `pm2 stop privydrop-frontend`
|
||||
|
||||
### 4.7. 日常增量更新(本地构建 + 远程替换)
|
||||
|
||||
本小节介绍如何在本地构建后,将前后端的生产产物一并打包上传到服务器,完成“增量更新”。该流程适合日常发布,速度快、资源占用低。
|
||||
|
||||
- 默认假设你已按“首次部署”完成环境配置(包括 PM2、Nginx/证书等),并能正常访问应用。
|
||||
- 默认使用前端 Next.js Standalone 运行方式(ecosystem.config.js 已配置),服务器无需安装前端依赖和 next CLI。
|
||||
|
||||
0. 同步前端生产环境变量(重要)
|
||||
|
||||
本地构建会读取 `frontend/.env.production` 中的变量(例如 `NEXT_PUBLIC_API_URL`、TURN 配置、以及构建期开关 `NEXT_IMAGE_UNOPTIMIZED` 等)。
|
||||
为确保构建产物与线上一致,请在执行 `bash build-and-deploy.sh` 之前,将“线上部署环境的 `frontend/.env.production`”拷贝到本地对应路径。
|
||||
|
||||
- 示例(从服务器同步到本地):
|
||||
```bash
|
||||
# 假设服务器项目根目录为 /root/PrivyDrop
|
||||
scp root@<server>:/root/PrivyDrop/frontend/.env.production ./frontend/.env.production
|
||||
```
|
||||
- 如线上暂未建立该文件,可基于示例创建并与线上保持一致:
|
||||
```bash
|
||||
cp frontend/.env_production_example frontend/.env.production
|
||||
# 按需填写 NEXT_PUBLIC_API_URL、TURN_*、NEXT_IMAGE_UNOPTIMIZED 等
|
||||
```
|
||||
- 说明:`build-and-deploy.sh` 不会自动生成/覆盖你的 `frontend/.env.production`,请确保本地文件存在且与线上一致,否则可能出现与线上不一致的行为(例如图片优化开关不同导致的差异)。
|
||||
|
||||
1. 准备部署配置
|
||||
|
||||
- 在项目根目录复制示例配置:
|
||||
```bash
|
||||
cp deploy.config.example deploy.config
|
||||
```
|
||||
- 编辑 `deploy.config`,至少设置:
|
||||
```bash
|
||||
DEPLOY_SERVER="<你的服务器IP或域名>"
|
||||
DEPLOY_USER="root" # 推荐使用 ssh root 登录(简单直接)
|
||||
DEPLOY_PATH="/root/PrivyDrop" # 你的服务器项目根目录
|
||||
# 可选:SSH_PORT、SSH_KEY_PATH
|
||||
```
|
||||
- 安全建议:生产环境请启用密钥登录、限制来源 IP、开启防火墙(仅放行必要端口)。
|
||||
|
||||
2. 本地构建并部署
|
||||
|
||||
- 在项目根目录执行:
|
||||
```bash
|
||||
bash build-and-deploy.sh
|
||||
```
|
||||
- 当脚本检测到现有打包(out.zip)时,可选择:
|
||||
- 1. 直接部署现有包
|
||||
- 2. 重新构建并部署
|
||||
- 脚本流程(简述):
|
||||
- 本地构建前端与后端
|
||||
- 将产物打包为 `out.zip`
|
||||
- 上传至服务器 `/tmp/out.zip`
|
||||
- 服务器侧备份当前版本到 `/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`
|
||||
- 解压替换:
|
||||
- 前端:`frontend/.next`(包含 `.next/standalone` 与 `.next/static`)
|
||||
- 前端静态资源:`frontend/public`
|
||||
- 前端内容:`frontend/content`(用于博客文件读取)
|
||||
- 后端:`backend/dist`
|
||||
- 使用 `pm2 start ecosystem.config.js` 重启应用
|
||||
|
||||
3. 发布校验
|
||||
|
||||
- 服务器上查看进程状态:
|
||||
```bash
|
||||
ssh root@<server> 'sudo pm2 status'
|
||||
```
|
||||
- 核对前端 BUILD_ID(可选):
|
||||
```bash
|
||||
ssh root@<server> 'cat /root/PrivyDrop/frontend/.next/BUILD_ID'
|
||||
```
|
||||
- 浏览器强制刷新或使用隐身模式,确认页面为新版本。
|
||||
|
||||
4. 备份和回退(手工)
|
||||
|
||||
- 每次部署会在服务器保存结构化备份:`/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`
|
||||
- 前端:`frontend/.next`
|
||||
- 后端:`backend/dist`
|
||||
- 如需回退,可手工执行(示例):
|
||||
|
||||
```bash
|
||||
# 停止 PM2
|
||||
sudo pm2 stop all && sudo pm2 delete all
|
||||
|
||||
# 假设选定备份目录为 /tmp/privydrop_backup/20241024_235959
|
||||
export DEPLOY_PATH=/root/PrivyDrop
|
||||
export BACKUP=/tmp/privydrop_backup/20241024_235959
|
||||
|
||||
# 恢复前端与后端构建产物
|
||||
rm -rf "$DEPLOY_PATH/frontend/.next" "$DEPLOY_PATH/backend/dist"
|
||||
cp -a "$BACKUP/frontend/.next" "$DEPLOY_PATH/frontend/.next"
|
||||
cp -a "$BACKUP/backend/dist" "$DEPLOY_PATH/backend/dist"
|
||||
|
||||
# 重启 PM2
|
||||
sudo pm2 start ecosystem.config.js
|
||||
```
|
||||
|
||||
5. 常见问题
|
||||
- 页面仍显示旧版本:清除浏览器缓存/强制刷新;核对 BUILD_ID;检查 Nginx/CDN 缓存。
|
||||
- 前端博客文章为空:确认服务器目录存在 `frontend/content/blog`,并确保 PM2 前端进程的 `cwd` 为 `./frontend`。
|
||||
- 部署脚本报错 `out.zip not found`:先选择“重新构建并部署”。
|
||||
|
||||
## 5. 故障排除
|
||||
|
||||
- **连接问题:** 检查防火墙、Nginx 代理设置、CORS_ORIGIN 配置,确保所有 PM2 进程都在运行。
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
# PrivyDrop Docker One-Click Deployment (Recommended)
|
||||
|
||||
This guide provides a one-click Docker deployment for PrivyDrop. It supports both private and public networks, automates config/build/start, and provisions HTTPS certificates.
|
||||
|
||||
## 🚀 Quick Start (Top)
|
||||
|
||||
```bash
|
||||
# Private LAN (no domain/public IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# Private LAN + TURN (for complex NAT/LAN)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# Public IP without domain (with TURN)
|
||||
bash ./deploy.sh --mode public --with-turn
|
||||
|
||||
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew certs)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
- Requires Docker Compose v2 (command `docker compose`).
|
||||
- In full mode, Let’s Encrypt (webroot) is auto-issued and auto-renewed (no downtime); SNI 443 multiplexing is enabled by default (`turn.your-domain.com` → coturn:5349; others → web:8443).
|
||||
|
||||
## Modes Overview
|
||||
|
||||
- lan-http: Intranet HTTP; fastest to start; no TLS
|
||||
- lan-tls: Intranet HTTPS (self-signed; dev/managed env); 8443 disabled by default; enable via `--enable-web-https`; HSTS disabled; turns:443 not guaranteed
|
||||
- public: Public HTTP + TURN; works without a domain (no HTTPS/turns:443)
|
||||
- full: Domain + HTTPS (Let’s Encrypt auto-issue/renew) + TURN; SNI 443 split enabled by default (use `--no-sni443` to disable)
|
||||
|
||||
## 🎯 Deployment Advantages
|
||||
|
||||
Compared to traditional deployment methods, Docker deployment offers the following advantages:
|
||||
|
||||
| Comparison | Traditional Deployment | Docker Deployment |
|
||||
| ---------------------------- | ------------------------------- | ------------------------------ |
|
||||
| **Deploy Time** | 30-60 minutes | 5 minutes |
|
||||
| **Technical Requirements** | Linux ops experience | Basic Docker knowledge |
|
||||
| **Environment Requirements** | Public IP + Domain | Works on private networks |
|
||||
| **Configuration Complexity** | 10+ manual steps | One-click auto configuration |
|
||||
| **Success Rate** | ~70% | >95% |
|
||||
| **Maintenance Difficulty** | Manual multi-service management | Automatic container management |
|
||||
|
||||
## 📋 System Requirements
|
||||
|
||||
### Minimum Configuration
|
||||
|
||||
- **CPU**: 1 core
|
||||
- **Memory**: 512MB
|
||||
- **Disk**: 2GB available space
|
||||
- **Network**: Any network environment (private/public)
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
- **CPU**: 2+ cores
|
||||
- **Memory**: 1GB+
|
||||
- **Disk**: 5GB+ available space
|
||||
- **Network**: 100Mbps+
|
||||
|
||||
### Software Dependencies
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.x (command `docker compose`)
|
||||
- curl (for health checks, optional)
|
||||
- openssl (cert tools; the script auto-installs certbot)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Get the Code
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd PrivyDrop
|
||||
```
|
||||
|
||||
### 2. One-Click Deployment
|
||||
|
||||
```bash
|
||||
# Basic deployment (recommended for beginners)
|
||||
bash ./deploy.sh
|
||||
```
|
||||
|
||||
That's it! 🎉
|
||||
|
||||
## 📚 Deployment Modes
|
||||
|
||||
### LAN HTTP (lan-http)
|
||||
|
||||
**Use Case**: Private network file transfer, personal use, testing environment
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-http
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ HTTP access
|
||||
- ✅ Private network P2P transfer
|
||||
- ✅ Uses public STUN servers
|
||||
- ✅ Zero configuration startup
|
||||
|
||||
### Public Mode
|
||||
|
||||
**Use Case**: Servers with public IP but no domain
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ HTTP access
|
||||
- ✅ Built-in TURN server
|
||||
- ✅ Supports complex network environments
|
||||
- ✅ Automatic NAT traversal configuration
|
||||
|
||||
### Full Mode (full)
|
||||
|
||||
**Use Case**: Production environment, public servers with domain
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ HTTPS secure access (Let’s Encrypt auto-issue/renew, zero downtime)
|
||||
- ✅ Nginx reverse proxy
|
||||
- ✅ Built-in TURN server (default port range 49152-49252/udp)
|
||||
- ✅ SNI 443 multiplexing (turn.<domain> → coturn:5349; others → web:8443)
|
||||
- ✅ Complete production setup
|
||||
|
||||
> Tip: The script no longer auto-detects the deployment mode; always pass `--mode lan-http|lan-tls|public|full`. If the detected LAN IP is not the one you expect, add `--local-ip 192.168.x.x` to override.
|
||||
|
||||
## 🔧 Advanced Configuration
|
||||
|
||||
### Custom Ports
|
||||
|
||||
```bash
|
||||
# Modify .env file
|
||||
FRONTEND_PORT=8080
|
||||
BACKEND_PORT=8081
|
||||
HTTP_PORT=8000
|
||||
```
|
||||
|
||||
### Build-Time Proxy (optional)
|
||||
|
||||
Set the following variables in `.env` (or export them before running `deploy.sh`) when the build needs to go through a proxy. The configuration generator now preserves these fields on subsequent runs.
|
||||
|
||||
```bash
|
||||
HTTP_PROXY=http://your-proxy:7890
|
||||
HTTPS_PROXY=http://your-proxy:7890
|
||||
NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
|
||||
```
|
||||
|
||||
`docker compose` passes these values as build args; the Dockerfiles expose them as environment variables so `npm`/`pnpm` automatically reuse the proxy. Leave them blank if you don't need a proxy.
|
||||
|
||||
### Common Flags
|
||||
|
||||
```bash
|
||||
# Enable only Nginx reverse proxy
|
||||
bash ./deploy.sh --with-nginx
|
||||
|
||||
# Enable TURN (recommended in public/full)
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# Explicitly enable SNI 443 (auto-enabled in full+domain; use --no-sni443 to disable)
|
||||
bash ./deploy.sh --with-sni443
|
||||
|
||||
# Adjust TURN port range (default 49152-49252/udp)
|
||||
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
||||
```
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
- With Nginx (recommended, same-origin gateway)
|
||||
- lan-http/public: `http://localhost` (or `http://<public IP>`)
|
||||
- lan-tls (with `--enable-web-https`): `https://localhost:8443` (or `https://<LAN IP>:8443`)
|
||||
- full (with domain): `https://<your-domain>` (443)
|
||||
- Health checks: `curl -fsS http://localhost/api/health` (lan-http/public), `curl -kfsS https://localhost:8443/api/health` (lan-tls+https), `curl -fsS https://<domain>/api/health` (full)
|
||||
|
||||
- Without Nginx (direct ports, for debugging only)
|
||||
- Frontend: `http://localhost:3002` (or `http://<LAN IP>:3002`)
|
||||
- API: `http://localhost:3001` (or `http://<LAN IP>:3001`)
|
||||
- Note: direct ports may cause CORS or 404 in production/public setups and are not recommended for public access.
|
||||
|
||||
### HTTPS Access (lan-tls/full)
|
||||
|
||||
- lan-tls: with `--enable-web-https`, access via `https://localhost:8443` (certs in `docker/ssl/`). Import `docker/ssl/ca-cert.pem` into your browser or trust store on first use.
|
||||
- full: after Let’s Encrypt issuance, access via `https://<your-domain>` (443). Certs auto-issue/renew; hot-reload is handled via deploy hook.
|
||||
|
||||
## 🔍 Management Commands
|
||||
|
||||
### View Service Status
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### View Service Logs
|
||||
|
||||
```bash
|
||||
# View all service logs
|
||||
docker compose logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f redis
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
# Restart all services
|
||||
docker compose restart
|
||||
|
||||
# Restart specific service
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
### Stop Services
|
||||
|
||||
```bash
|
||||
# Stop services but keep data
|
||||
docker compose stop
|
||||
|
||||
# Stop services and remove containers
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Complete Cleanup
|
||||
|
||||
```bash
|
||||
# Clean all containers, images and data
|
||||
bash ./deploy.sh --clean
|
||||
```
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Port Already in Use
|
||||
|
||||
**Symptom**: Deployment shows port occupation warning
|
||||
|
||||
```
|
||||
⚠️ The following ports are already in use: 3002, 3001
|
||||
```
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# First try cleaning previous containers
|
||||
bash ./deploy.sh --clean # or docker compose down
|
||||
|
||||
# If the port is still occupied, locate the process
|
||||
sudo ss -tulpn | grep :3002
|
||||
sudo kill -9 <PID>
|
||||
|
||||
# Finally, adjust the exposed ports in .env if necessary
|
||||
vim .env # Update FRONTEND_PORT / BACKEND_PORT
|
||||
```
|
||||
|
||||
#### 2. Insufficient Memory
|
||||
|
||||
**Symptom**: Containers fail to start or restart frequently
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Check memory usage
|
||||
free -h
|
||||
|
||||
# Add swap space (temporary solution)
|
||||
sudo fallocate -l 1G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
#### 3. Docker Permission Issues
|
||||
|
||||
**Symptom**: Permission denied errors
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Re-login or refresh group permissions
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
#### 4. Service Inaccessible
|
||||
|
||||
**Symptom**: Browser cannot open pages
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# 1. Check service status
|
||||
docker compose ps
|
||||
|
||||
# 2. Check health status
|
||||
curl http://localhost:3001/health
|
||||
curl http://localhost:3002/api/health
|
||||
|
||||
# 3. View detailed logs
|
||||
docker compose logs -f
|
||||
|
||||
# 4. Check firewall
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
#### 5. WebRTC Connection Failure
|
||||
|
||||
**Symptom**: Cannot establish P2P connections
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Enable TURN server
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# Check network connectivity
|
||||
curl -I http://localhost:3001/api/get_room
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
The project provides comprehensive health check functionality:
|
||||
|
||||
```bash
|
||||
# Run health check tests
|
||||
bash test-health-apis.sh
|
||||
|
||||
# Manual service checks
|
||||
curl http://localhost:3001/health # Backend basic check
|
||||
curl http://localhost:3001/health/detailed # Backend detailed check
|
||||
curl http://localhost:3002/api/health # Frontend check
|
||||
```
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
```bash
|
||||
# View container resource usage
|
||||
docker stats
|
||||
|
||||
# View disk usage
|
||||
docker system df
|
||||
|
||||
# Clean unused resources
|
||||
docker system prune -f
|
||||
```
|
||||
|
||||
## 📊 Performance Optimization
|
||||
|
||||
### Production Environment Optimization
|
||||
|
||||
1. **Enable Nginx Caching**:
|
||||
|
||||
```bash
|
||||
bash deploy.sh --with-nginx
|
||||
```
|
||||
|
||||
2. **Configure Resource Limits**:
|
||||
|
||||
```yaml
|
||||
# Add to docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 128M
|
||||
```
|
||||
|
||||
3. **Enable Log Rotation**:
|
||||
|
||||
```bash
|
||||
# Configure log size limits
|
||||
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### Network Optimization
|
||||
|
||||
1. **Use Dedicated Network**:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
privydrop-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
```
|
||||
|
||||
2. **Enable HTTP/2**:
|
||||
|
||||
```bash
|
||||
# Auto-enabled (requires HTTPS)
|
||||
bash deploy.sh --mode full --with-nginx
|
||||
```
|
||||
|
||||
## 🔒 Security Configuration
|
||||
|
||||
### LAN HTTPS (lan-tls, self-signed, dev/managed env)
|
||||
|
||||
- 8443 is disabled by default; explicitly enable with:
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
```
|
||||
|
||||
- For development or managed devices only (internal CA trusted fleet-wide); HSTS disabled; `turns:443` not guaranteed. For restricted networks (443-only), use full (domain + trusted cert + SNI 443).
|
||||
|
||||
Usage (strongly recommended)
|
||||
|
||||
1) Import the self-signed CA (required)
|
||||
- Location: `docker/ssl/ca-cert.pem`
|
||||
- Browser import:
|
||||
- Chrome/Edge: Settings → Privacy & Security → Security → Manage certificates → “Trusted Root Certification Authorities” → Import `ca-cert.pem`
|
||||
- macOS: Keychain Access → System → Certificates → Import `ca-cert.pem` → set to “Always Trust”
|
||||
- Linux (system-wide):
|
||||
- `sudo cp docker/ssl/ca-cert.pem /usr/local/share/ca-certificates/privydrop-ca.crt`
|
||||
- `sudo update-ca-certificates`
|
||||
- Without trusting the CA, browser HTTPS will show untrusted cert warnings and API requests will fail.
|
||||
|
||||
2) Access endpoints (default ports and paths)
|
||||
- Nginx reverse proxy: `http://localhost`
|
||||
- HTTPS (Web): `https://localhost:8443`, `https://<LAN IP>:8443`
|
||||
- Frontend direct (optional): `http://localhost:3002`, `http://<LAN IP>:3002`
|
||||
- Note: In lan-tls, 443 is not open; HTTPS uses 8443.
|
||||
|
||||
3) CORS
|
||||
- For convenience, common dev origins are allowed by default: `https://<LAN IP>:8443`, `https://localhost:8443`, `http://localhost`, `http://<LAN IP>`, `http://localhost:3002`, `http://<LAN IP>:3002`.
|
||||
- To minimize allowed origins, edit `CORS_ORIGIN` in `.env` and then `docker compose restart backend`.
|
||||
|
||||
4) Health checks
|
||||
- `curl -kfsS https://localhost:8443/api/health` → 200
|
||||
- `bash ./test-health-apis.sh` → all tests should pass (frontend container trusts the self-signed CA).
|
||||
|
||||
5) Deployment hints
|
||||
- The script prints only reachable Nginx endpoints; in lan-tls it will show `https://localhost:8443` (and `https://<LAN IP>:8443` if available).
|
||||
|
||||
### Public Domain Deployment (HTTPS + Nginx) — Quick Test
|
||||
|
||||
1) Point your domain A record to the server IP (optional: also `turn.<your-domain>` to the same IP)
|
||||
|
||||
2) Run:
|
||||
|
||||
```bash
|
||||
./deploy.sh --mode full --domain <your-domain> --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
3) Open ports: `80`, `443`, `3478/udp`, `5349/tcp`, `5349/udp`
|
||||
|
||||
4) Verify: visit `https://<your-domain>`, `/api/health` returns 200; open `chrome://webrtc-internals` and check for `relay` candidates (TURN)
|
||||
|
||||
### SSL/TLS Automation (Let’s Encrypt)
|
||||
|
||||
In full mode, certificates are auto-issued and auto-renewed:
|
||||
|
||||
- Initial issuance: webroot (no downtime); system certs live under `/etc/letsencrypt/live/<domain>/`; copied to `docker/ssl/` and 443 is enabled.
|
||||
- Renewal: `certbot.timer` or `/etc/cron.d/certbot` runs daily; the deploy-hook copies new certs to `docker/ssl/` and hot-reloads Nginx/Coturn.
|
||||
- Lineage suffixes (-0001/-0002) are handled automatically.
|
||||
|
||||
### Network Security
|
||||
|
||||
1. **Firewall Configuration**:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 3478/udp # TURN server
|
||||
```
|
||||
|
||||
2. **Container Network Isolation**:
|
||||
- All services run in isolated networks
|
||||
- Only necessary ports exposed
|
||||
- Internal services communicate using container names
|
||||
|
||||
## 📈 Monitoring and Logging
|
||||
|
||||
### Log Management
|
||||
|
||||
All service logs are centrally stored in the `logs/` directory:
|
||||
|
||||
```
|
||||
logs/
|
||||
├── nginx/ # Nginx access and error logs
|
||||
├── backend/ # Backend application logs
|
||||
├── frontend/ # Frontend application logs
|
||||
└── coturn/ # TURN server logs
|
||||
```
|
||||
|
||||
## 🔄 Updates and Maintenance
|
||||
|
||||
### Update Application
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# Redeploy
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
### Data Backup
|
||||
|
||||
```bash
|
||||
# Backup Redis data
|
||||
docker compose exec redis redis-cli BGSAVE
|
||||
|
||||
# Backup SSL certificates
|
||||
tar -czf ssl-backup.tar.gz docker/ssl/
|
||||
|
||||
# Backup configuration files
|
||||
cp .env .env.backup
|
||||
```
|
||||
|
||||
### Regular Maintenance
|
||||
|
||||
```bash
|
||||
# Clean unused images and containers
|
||||
docker system prune -f
|
||||
|
||||
# Update base images
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🆘 Getting Help
|
||||
|
||||
### Command Line Help
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --help
|
||||
|
||||
### Additional Notes
|
||||
|
||||
- In Docker environments, Next.js Image optimization is disabled by default (`NEXT_IMAGE_UNOPTIMIZED=true`) to avoid container loopback fetch failures on `/_next/image`. To enable it, set the variable to `false` and rebuild.
|
||||
- With `--with-nginx`, the frontend is built to use same-origin API (`/api`, `/socket.io/`). Use the gateway URLs printed by the script; direct ports `:3002/:3001` are not recommended in production.
|
||||
```
|
||||
|
||||
### Online Resources
|
||||
|
||||
- [Project Homepage](https://github.com/david-bai00/PrivyDrop)
|
||||
- [Live Demo](https://www.privydrop.app/)
|
||||
- [Issue Reporting](https://github.com/david-bai00/PrivyDrop/issues)
|
||||
|
||||
### Community Support
|
||||
|
||||
- GitHub Issues: Technical questions and bug reports
|
||||
@@ -0,0 +1,566 @@
|
||||
# PrivyDrop Docker 一键部署(推荐)
|
||||
|
||||
本指南提供 PrivyDrop 的 Docker 一键部署方案,支持内网与公网,一次命令完成配置、构建、启动与证书自动化。
|
||||
|
||||
## 🚀 快速开始(置顶)
|
||||
|
||||
```bash
|
||||
# 内网(无域名/无公网IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# 内网 + TURN(推荐用于复杂内网/NAT)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# 内网 HTTPS(自签,开发/受管环境,需显式开启 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# 公网IP(无域名),含 TURN
|
||||
bash ./deploy.sh --mode public --with-turn
|
||||
|
||||
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
- 使用 Docker Compose V2(命令 `docker compose`)。
|
||||
- full 模式自动申请 Let’s Encrypt 证书(webroot,无停机)并自动续期;默认启用 SNI 443 分流(`turn.your-domain.com` → coturn:5349,其余 → web:8443)。
|
||||
|
||||
## 模式一览
|
||||
|
||||
- lan-http:内网 HTTP;最快上手,默认不启用 TLS
|
||||
- lan-tls:内网 HTTPS(自签,仅开发/受管环境);默认不启 8443,需 `--enable-web-https` 显式开启;禁用 HSTS;不保证 turns:443
|
||||
- public:公网 HTTP;开启 TURN;无域名也可使用(不提供 HTTPS/turns:443)
|
||||
- full:域名 + HTTPS(Let’s Encrypt 自动签发/续期)+ TURN;默认启用 SNI 443 分流(可 `--no-sni443` 关闭)
|
||||
|
||||
## 🎯 部署优势
|
||||
|
||||
相比传统部署方式,Docker 部署具有以下优势:
|
||||
|
||||
| 对比项目 | 传统部署 | Docker 部署 |
|
||||
| -------------- | -------------------- | ---------------- |
|
||||
| **部署时间** | 30-60 分钟 | 5 分钟 |
|
||||
| **技术要求** | Linux 运维经验 | 会用 Docker 即可 |
|
||||
| **环境要求** | 公网 IP + 域名 | 内网即可使用 |
|
||||
| **配置复杂度** | 10+个手动步骤 | 一键自动配置 |
|
||||
| **成功率** | ~70% | >95% |
|
||||
| **维护难度** | 需要手动管理多个服务 | 容器自动管理 |
|
||||
|
||||
## 📋 系统要求
|
||||
|
||||
### 最低配置
|
||||
|
||||
- **CPU**: 1 核
|
||||
- **内存**: 512MB
|
||||
- **磁盘**: 2GB 可用空间
|
||||
- **网络**: 任意网络环境(内网/公网均可)
|
||||
|
||||
### 推荐配置
|
||||
|
||||
- **CPU**: 2 核及以上
|
||||
- **内存**: 1GB 及以上
|
||||
- **磁盘**: 5GB 及以上可用空间
|
||||
- **网络**: 100Mbps 及以上
|
||||
|
||||
### 软件依赖
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.x(命令 `docker compose`)
|
||||
- curl(用于健康检查,可选)
|
||||
- openssl(用于证书工具,脚本会自动安装 certbot)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 获取代码
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd PrivyDrop
|
||||
```
|
||||
|
||||
### 2. 一键部署(示例)
|
||||
|
||||
```bash
|
||||
# 示例:公网域名(HTTPS + Nginx + TURN)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
## 📚 部署模式详解
|
||||
|
||||
### lan-http(内网 HTTP)
|
||||
|
||||
**适用场景**: 内网文件传输、个人使用、测试环境
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-http
|
||||
```
|
||||
|
||||
**特性**:
|
||||
|
||||
- ✅ HTTP 访问
|
||||
- ✅ 内网 P2P 传输
|
||||
- ✅ 使用公共 STUN 服务器
|
||||
- ✅ 零配置启动
|
||||
|
||||
### 公网模式
|
||||
|
||||
**适用场景**: 有公网 IP 但无域名的服务器
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
```
|
||||
|
||||
**特性**:
|
||||
|
||||
- ✅ HTTP 访问
|
||||
- ✅ 内置 TURN 服务器
|
||||
- ✅ 支持复杂网络环境
|
||||
- ✅ 自动配置 NAT 穿透
|
||||
|
||||
### 完整模式(full)
|
||||
|
||||
**适用场景**: 生产环境、有域名的公网服务器
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
**特性**:
|
||||
|
||||
- ✅ HTTPS 安全访问(Let’s Encrypt 自动签发/续期,无停机)
|
||||
- ✅ Nginx 反向代理
|
||||
- ✅ 内置 TURN 服务器(默认端口段 49152-49252/udp,可覆盖)
|
||||
- ✅ SNI 443 分流(turn.<domain> → coturn:5349,其余 → web:8443)
|
||||
- ✅ 完整生产环境配置
|
||||
|
||||
> 提示:脚本不再自动判断部署模式,请显式传递 `--mode lan-http|lan-tls|public|full`。若自动检测到的局域网 IP 与预期不符,可使用 `--local-ip 192.168.x.x` 进行覆盖。
|
||||
|
||||
## 🔧 高级配置
|
||||
|
||||
### 自定义端口
|
||||
|
||||
```bash
|
||||
# 修改 .env 文件
|
||||
FRONTEND_PORT=8080
|
||||
BACKEND_PORT=8081
|
||||
HTTP_PORT=8000
|
||||
```
|
||||
|
||||
### 构建阶段代理(可选)
|
||||
|
||||
若需要在 Docker 构建时走网络代理,可在 `.env` 中设置以下变量,或者在执行 `deploy.sh` 之前通过环境变量导出。重新运行配置脚本时,这些字段会被保留:
|
||||
|
||||
```bash
|
||||
HTTP_PROXY=http://你的代理:7890
|
||||
HTTPS_PROXY=http://你的代理:7890
|
||||
NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
|
||||
```
|
||||
|
||||
`docker compose` 会把这些变量作为 build args 传递给前后端镜像,Dockerfile 中会自动设置为环境变量,从而让 `npm`/`pnpm` 使用代理。若无需代理,保持为空即可。
|
||||
|
||||
### 常用开关
|
||||
|
||||
```bash
|
||||
# 仅启用 Nginx
|
||||
bash ./deploy.sh --with-nginx
|
||||
|
||||
# 启用 TURN(public/full 建议)
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# 显式启用 SNI 443(full+domain 默认开启,可用 --no-sni443 关闭)
|
||||
bash ./deploy.sh --with-sni443
|
||||
|
||||
# 调整 TURN 端口段(默认 49152-49252/udp)
|
||||
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
||||
```
|
||||
|
||||
## 🌐 访问方式
|
||||
|
||||
- 启用 Nginx(推荐,同源网关)
|
||||
- lan-http/public:`http://localhost`(或 `http://<公网IP>`)
|
||||
- lan-tls(启用 `--enable-web-https`):`https://localhost:8443`(或 `https://<LAN IP>:8443`)
|
||||
- full(有域名):`https://<your-domain>`(443)
|
||||
- 健康检查:`curl -fsS http://localhost/api/health`(lan-http/public),`curl -kfsS https://localhost:8443/api/health`(lan-tls+https),`curl -fsS https://<domain>/api/health`(full)
|
||||
|
||||
- 未启用 Nginx(直连端口,仅调试)
|
||||
- 前端:`http://localhost:3002`(或 `http://<LAN IP>:3002`)
|
||||
- API:`http://localhost:3001`(或 `http://<LAN IP>:3001`)
|
||||
- 注意:直连端口在生产/公网可能导致 CORS 或 404,不推荐对外使用。
|
||||
|
||||
### HTTPS 访问(lan-tls/full)
|
||||
|
||||
- lan-tls:开启 `--enable-web-https` 后通过 `https://localhost:8443` 访问(证书在 `docker/ssl/`)。首次访问需导入 `docker/ssl/ca-cert.pem` 到浏览器或系统信任。
|
||||
- full:签发 Let’s Encrypt 后通过 `https://<your-domain>` 访问(443)。
|
||||
|
||||
## 🔍 管理命令
|
||||
|
||||
### 查看服务状态
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 查看服务日志
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker compose logs -f
|
||||
|
||||
# 查看特定服务日志
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f redis
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启所有服务
|
||||
docker compose restart
|
||||
|
||||
# 重启特定服务
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
### 停止服务
|
||||
|
||||
```bash
|
||||
# 停止服务但保留数据
|
||||
docker compose stop
|
||||
|
||||
# 停止服务并删除容器
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 完全清理
|
||||
|
||||
```bash
|
||||
# 清理所有容器、镜像和数据
|
||||
bash ./deploy.sh --clean
|
||||
```
|
||||
|
||||
## 🛠️ 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 端口被占用
|
||||
|
||||
**现象**: 部署时提示端口已被占用
|
||||
|
||||
```
|
||||
⚠️ 以下端口已被占用: 3002, 3001
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 方法1: 清理旧容器
|
||||
bash ./deploy.sh --clean # 或 docker compose down
|
||||
|
||||
# 方法2: 查找并结束占用进程
|
||||
sudo ss -tulpn | grep :3002
|
||||
sudo kill -9 <PID>
|
||||
|
||||
# 方法3: 如仍冲突,再调整端口
|
||||
vim .env # 修改 FRONTEND_PORT / BACKEND_PORT
|
||||
```
|
||||
|
||||
#### 2. 内存不足
|
||||
|
||||
**现象**: 容器启动失败或频繁重启
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 检查内存使用
|
||||
free -h
|
||||
|
||||
# 添加交换空间 (临时解决)
|
||||
sudo fallocate -l 1G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
#### 3. Docker 权限问题
|
||||
|
||||
**现象**: 提示权限不足
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 将用户添加到docker组
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# 重新登录或刷新组权限
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
#### 4. 服务无法访问
|
||||
|
||||
**现象**: 浏览器无法打开页面
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 1. 检查服务状态
|
||||
docker compose ps
|
||||
|
||||
# 2. 检查健康状态
|
||||
curl http://localhost:3001/health
|
||||
curl http://localhost:3002/api/health
|
||||
|
||||
# 3. 查看详细日志
|
||||
docker compose logs -f
|
||||
|
||||
# 4. 检查防火墙
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
#### 5. WebRTC 连接失败
|
||||
|
||||
**现象**: 无法建立 P2P 连接
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 启用TURN服务器
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# 检查网络连接
|
||||
curl -I http://localhost:3001/api/get_room
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
项目提供了完整的健康检查功能:
|
||||
|
||||
```bash
|
||||
# 运行健康检查测试
|
||||
bash ./test-health-apis.sh
|
||||
|
||||
# 手动检查各服务(直连端口)
|
||||
curl http://localhost:3001/health # 后端基础检查(直连)
|
||||
curl http://localhost:3001/health/detailed # 后端详细检查(直连)
|
||||
curl http://localhost:3002/api/health # 前端检查(直连)
|
||||
|
||||
# 同源网关(启用 Nginx)
|
||||
curl -fsS http://localhost/api/health # lan-http/public
|
||||
curl -kfsS https://localhost:8443/api/health # lan-tls (https)
|
||||
```
|
||||
|
||||
### 性能监控
|
||||
|
||||
```bash
|
||||
# 查看容器资源使用
|
||||
docker stats
|
||||
|
||||
# 查看磁盘使用
|
||||
docker system df
|
||||
|
||||
# 清理未使用的资源
|
||||
docker system prune -f
|
||||
```
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
### 生产环境优化
|
||||
|
||||
1. **启用 Nginx 缓存**:
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --with-nginx
|
||||
```
|
||||
|
||||
2. **配置资源限制**:
|
||||
|
||||
```yaml
|
||||
# 在 docker-compose.yml 中添加
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 128M
|
||||
```
|
||||
|
||||
3. **启用日志轮转**:
|
||||
|
||||
```bash
|
||||
# 配置日志大小限制
|
||||
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### 网络优化
|
||||
|
||||
1. **使用专用网络**:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
privydrop-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
```
|
||||
|
||||
2. **启用 HTTP/2**:
|
||||
|
||||
```bash
|
||||
# 自动启用 (需要 HTTPS)
|
||||
bash ./deploy.sh --mode full --with-nginx
|
||||
```
|
||||
|
||||
## 🔒 HTTPS 与安全
|
||||
|
||||
### 内网 HTTPS(lan-tls,自签,开发/受管环境)
|
||||
|
||||
- 默认不启 8443;需 `--enable-web-https` 显式开启:
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
```
|
||||
|
||||
- 仅用于开发或受管终端(全员导入内部 CA);禁用 HSTS;不保证 `turns:443`;受限网络(仅 443 出口)应使用 full(域名 + 受信证书 + SNI 443)。
|
||||
|
||||
使用说明(强烈建议逐条完成)
|
||||
|
||||
1) 导入自签 CA 证书(必做)
|
||||
- 证书位置:`docker/ssl/ca-cert.pem`
|
||||
- 浏览器导入:
|
||||
- Chrome/Edge:设置 → 隐私与安全 → 安全 → 管理证书 → “受信任的根证书颁发机构” → 导入 `ca-cert.pem`
|
||||
- macOS:钥匙串访问 → System → 证书 → 导入 `ca-cert.pem` → 设置“始终信任”
|
||||
- Linux(系统层):
|
||||
- `sudo cp docker/ssl/ca-cert.pem /usr/local/share/ca-certificates/privydrop-ca.crt`
|
||||
- `sudo update-ca-certificates`
|
||||
- 未导入时,浏览器访问 HTTPS 会提示“证书无效/不受信任”,API 请求也会失败。
|
||||
|
||||
2) 访问方式(默认端口与路径)
|
||||
- Nginx 反代:`http://localhost`
|
||||
- HTTPS(Web):`https://localhost:8443`、`https://<局域网IP>:8443`
|
||||
- 前端直连(可选):`http://localhost:3002`、`http://<局域网IP>:3002`
|
||||
- 说明:lan-tls 下未开启 443;HTTPS 统一走 8443。
|
||||
|
||||
3) 跨域(CORS)说明
|
||||
- 为方便开发与调试,脚本已默认放开常见来源:`https://<局域网IP>:8443`、`https://localhost:8443`、`http://localhost`、`http://<局域网IP>`、`http://localhost:3002`、`http://<局域网IP>:3002`。
|
||||
- 若仍需最小化来源,请在 `.env` 中精准收敛 `CORS_ORIGIN`,并 `docker compose restart backend`。
|
||||
|
||||
4) 健康检查
|
||||
- `curl -kfsS https://localhost:8443/api/health` → 200
|
||||
- `bash ./test-health-apis.sh` → 所有测试应通过(前端 detailed 健康已在容器内信任自签 CA)。
|
||||
|
||||
5) 部署提示
|
||||
- 脚本会只显示可访问的 Nginx 入口;lan-tls 下将显示明确的 `https://localhost:8443`(如存在局域网 IP 也将显示 `https://<IP>:8443`)。
|
||||
|
||||
### 公网域名部署(HTTPS + Nginx)快速测试
|
||||
|
||||
1) 将域名 A 记录解析至服务器 IP(可选:`turn.<your-domain>` 指向相同 IP)
|
||||
|
||||
2) 运行:
|
||||
|
||||
```bash
|
||||
./deploy.sh --mode full --domain <your-domain> --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
3) 放行端口:`80`, `443`, `3478/udp`, `5349/tcp`, `5349/udp`
|
||||
|
||||
4) 验证:访问 `https://<your-domain>`,`/api/health` 返回 200;打开浏览器 `webrtc-internals` 观察是否出现 `relay` 候选(TURN)
|
||||
|
||||
### 证书自动化(Let’s Encrypt)
|
||||
|
||||
full 模式自动申请并续期证书:
|
||||
|
||||
- 首次签发:webroot 模式(无停机),系统证书在 `/etc/letsencrypt/live/<domain>/`,脚本复制到 `docker/ssl/` 并启用 443;
|
||||
- 续期:`certbot.timer` 或 `/etc/cron.d/certbot` 每日尝试 `certbot renew`;deploy-hook 自动复制新证书并热重载 Nginx/Coturn;
|
||||
- 证书谱系(-0001/-0002)已自动适配,无需手动处理。
|
||||
|
||||
### 网络安全
|
||||
|
||||
1. **防火墙配置**:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 3478/udp # TURN服务器
|
||||
```
|
||||
|
||||
2. **容器网络隔离**:
|
||||
- 所有服务运行在独立网络中
|
||||
- 仅暴露必要端口
|
||||
- 内部服务使用容器名通信
|
||||
|
||||
## 📈 监控和日志
|
||||
|
||||
### 日志管理
|
||||
|
||||
所有服务日志统一存储在 `logs/` 目录:
|
||||
|
||||
```
|
||||
logs/
|
||||
├── nginx/ # Nginx访问和错误日志
|
||||
├── backend/ # 后端应用日志
|
||||
├── frontend/ # 前端应用日志
|
||||
└── coturn/ # TURN服务器日志
|
||||
```
|
||||
|
||||
## 🔄 更新和维护
|
||||
|
||||
### 更新应用
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 重新部署
|
||||
bash ./deploy.sh
|
||||
```
|
||||
|
||||
### 数据备份
|
||||
|
||||
```bash
|
||||
# 备份Redis数据
|
||||
docker compose exec redis redis-cli BGSAVE
|
||||
|
||||
# 备份SSL证书
|
||||
tar -czf ssl-backup.tar.gz docker/ssl/
|
||||
|
||||
# 备份配置文件
|
||||
cp .env .env.backup
|
||||
```
|
||||
|
||||
### 定期维护
|
||||
|
||||
```bash
|
||||
# 清理未使用的镜像和容器
|
||||
docker system prune -f
|
||||
|
||||
# 更新基础镜像
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🆘 获取帮助
|
||||
|
||||
### 命令行帮助
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --help
|
||||
```
|
||||
|
||||
### 在线资源
|
||||
|
||||
- [项目主页](https://github.com/david-bai00/PrivyDrop)
|
||||
- [在线演示](https://www.privydrop.app/)
|
||||
- [问题反馈](https://github.com/david-bai00/PrivyDrop/issues)
|
||||
|
||||
### 社区支持
|
||||
|
||||
- GitHub Issues: 技术问题和 bug 报告
|
||||
|
||||
### 其他提示
|
||||
|
||||
- Docker 环境默认关闭 Next.js Image 优化(`NEXT_IMAGE_UNOPTIMIZED=true`),以避免容器内对公网 IP 回环抓取导致 `/_next/image` 502;如需开启,将其设为 `false` 并重新构建。
|
||||
- 启用 `--with-nginx` 时,前端会构建为同源 API(相对路径 `/api`、`/socket.io/`);请优先使用脚本输出的网关入口,不要直接使用 `:3002/:3001` 对外访问,否则可能触发 CORS 或 404。
|
||||
@@ -19,7 +19,7 @@ In a recent refactor, we established a design philosophy centered on "**Separati
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **UI**: React 18, Tailwind CSS, shadcn/ui (based on Radix UI)
|
||||
- **State Management**: Modular state management centered on custom React Hooks
|
||||
- **State Management**: Zustand + custom React Hooks (modular business logic with shared app state)
|
||||
- **WebRTC Signaling**: Socket.IO Client
|
||||
- **Data Fetching**: React Server Components (RSC), Fetch API
|
||||
- **Internationalization**: `next/server` middleware + dynamic JSON dictionaries
|
||||
@@ -118,11 +118,16 @@ This section details how the application's most critical P2P transfer feature is
|
||||
|
||||
### 3.2 State Management Strategy
|
||||
|
||||
The project uses **custom React Hooks as the core for modular state management**. We deliberately avoided introducing global state management libraries (like Redux or Zustand) for the following reasons:
|
||||
The project adopts a combined approach of **Zustand + custom Hooks**:
|
||||
|
||||
- **Reduce Complexity**: For the current scale of the application, a global state would introduce unnecessary complexity.
|
||||
- **Promote Cohesion**: Encapsulating related state and logic within the same Hook makes the code easier to understand and maintain.
|
||||
- **Leverage React's Native Capabilities**: Passing state managed by Hooks through Context and Props is sufficient for all current needs.
|
||||
- **Zustand (shared app state)**: Manages cross-page/component application-level state such as room/connection states, send/receive progress, and UI states. Implementation lives in `frontend/stores/fileTransferStore.ts`, offering minimal boilerplate and strong type support.
|
||||
- **Custom Hooks (cohesive business logic)**: Complex flows (WebRTC connection, room management, file transfer orchestration) remain encapsulated within Hooks, preserving cohesion, testability, and reusability.
|
||||
|
||||
Benefits:
|
||||
|
||||
- **Clear boundaries**: Observable/shared data goes to Zustand; highly cohesive/transient state stays in each module/Hook.
|
||||
- **Less boilerplate & maintainable**: Zustand remains lightweight while Hooks keep composability and testability.
|
||||
- **Aligned with reality**: Keeps documentation consistent with the actual implementation.
|
||||
|
||||
### 3.3 Internationalization (i18n)
|
||||
|
||||
@@ -130,6 +135,14 @@ The project uses **custom React Hooks as the core for modular state management**
|
||||
- **Automatic Detection**: `middleware.ts` intercepts requests and automatically redirects to the appropriate language path based on the `Accept-Language` header or a cookie.
|
||||
- **Dynamic Loading**: The `getDictionary` function in `lib/dictionary.ts` asynchronously loads the corresponding `messages/*.json` file based on the `lang` parameter, enabling code splitting.
|
||||
|
||||
### 3.4 State & Connection Lifecycle (In-App Navigation)
|
||||
|
||||
- **Singleton Store (Zustand)**: `frontend/stores/fileTransferStore.ts` is a module-level singleton, preserving in-memory state across routes (e.g., share content, files to send, received files/meta, progress states).
|
||||
- **Singleton Connection Service (webrtcService)**: `frontend/lib/webrtcService.ts` holds `RTCPeerConnection`/`RTCDataChannel` and FileSender/FileReceiver as a singleton. App Router page switches do not tear it down.
|
||||
- **Effect**: In the same browser tab, in-app navigation (App Router page switches) does not interrupt ongoing transfers, and selected/received content remains intact.
|
||||
- **Boundary**: Page refresh/closing the tab or opening in a new tab is not covered; when changing layout hierarchy/SSR behavior, avoid cleaning the connection in layout unmount.
|
||||
- **Note**: Do not call `webrtcService.leaveRoom()` or reset the global Store inside route-change side effects; only do so on explicit user actions.
|
||||
|
||||
## 4. Summary and Outlook
|
||||
|
||||
The current frontend architecture successfully deconstructs a complex WebRTC application into a series of clean, maintainable modules through layered design and Hook-centric logic encapsulation. The boundaries between UI, business logic, and underlying libraries are clear, laying a solid foundation for future feature expansion and maintenance.
|
||||
|
||||
@@ -19,7 +19,7 @@ Privydrop 是一个基于 WebRTC 的 P2P 文件/文本分享工具,旨在提
|
||||
- **框架**: Next.js 14 (App Router)
|
||||
- **语言**: TypeScript
|
||||
- **UI**: React 18, Tailwind CSS, shadcn/ui (基于 Radix UI)
|
||||
- **状态管理**: 以自定义 React Hooks 为核心的模块化状态管理
|
||||
- **状态管理**: Zustand + 自定义 React Hooks(模块化业务逻辑与全局共享状态结合)
|
||||
- **WebRTC 信令**: Socket.IO Client
|
||||
- **数据获取**: React Server Components (RSC), Fetch API
|
||||
- **国际化**: `next/server` 中间件 + 动态 JSON 字典
|
||||
@@ -118,11 +118,16 @@ graph TD
|
||||
|
||||
### 3.2 状态管理策略
|
||||
|
||||
项目**以自定义 React Hooks 为核心进行模块化状态管理**。我们刻意避免了引入全局状态管理库(如 Redux, Zustand),理由如下:
|
||||
当前项目采用“Zustand + 自定义 Hooks”的组合策略:
|
||||
|
||||
- **降低复杂性**: 对于当前应用规模,全局状态会引入不必要的复杂性。
|
||||
- **促进内聚**: 将相关联的状态和逻辑封装在同一个 Hook 内,使得代码更易于理解和维护。
|
||||
- **利用 React 原生能力**: 通过 Context 和 Props 传递由 Hooks 管理的状态,足以满足当前所有需求。
|
||||
- **Zustand(全局共享状态)**: 用于管理跨页面/跨组件的应用级状态,例如房间与连接状态、发送/接收进度、UI 活动 Tab 等。实现位于 `frontend/stores/fileTransferStore.ts`,API 简洁、零样板、类型友好。
|
||||
- **自定义 Hooks(业务内聚)**: 复杂的业务流程(如 WebRTC 连接、房间管理、文件传输编排)仍以 Hooks 为边界进行封装,保持“逻辑内聚、可测试、可复用”。
|
||||
|
||||
这样做的收益:
|
||||
|
||||
- **边界清晰**: 全局可观察/可共享的数据进 Zustand,强业务内聚的瞬时/局部状态放在各自 Hook/模块内。
|
||||
- **减样板与可维护**: Zustand 足够轻量,不引入冗长样板;同时保留 Hooks 的可组合性和可测试性。
|
||||
- **更贴合现状**: 与代码实现保持一致,避免文档与实现脱节。
|
||||
|
||||
### 3.3 国际化 (i18n)
|
||||
|
||||
@@ -130,6 +135,14 @@ graph TD
|
||||
- **自动检测**: `middleware.ts` 拦截请求,根据 `Accept-Language` 头或 Cookie 自动重定向到合适的语言路径。
|
||||
- **动态加载**: `lib/dictionary.ts` 中的 `getDictionary` 函数根据 `lang` 参数异步加载对应的 `messages/*.json` 文件,实现了代码分割。
|
||||
|
||||
### 3.4 状态与连接生命周期(站内导航保持)
|
||||
|
||||
- **单例 Store(Zustand)**:`frontend/stores/fileTransferStore.ts` 为模块级单例,跨路由保持内存状态(如分享内容、待发送文件、已接收文件/元信息、进度等)。
|
||||
- **单例连接服务(webrtcService)**:`frontend/lib/webrtcService.ts` 单例持有 `RTCPeerConnection`/`RTCDataChannel` 与 FileSender/FileReceiver。App Router 的页面切换不会销毁该实例。
|
||||
- **效果**:在同一标签页内的站内跳转(App Router 页面切换),进行中的传输不会中断,已选择/已接收的内容保持不丢失。
|
||||
- **边界**:刷新/关闭标签页或新开标签页不在此保证范围内;SSR/布局层级调整时需确保不在布局卸载处做连接清理。
|
||||
- **注意**:不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置全局 Store;离开房间应仅在用户显式操作时触发。
|
||||
|
||||
## 四、 总结与展望
|
||||
|
||||
当前的前端架构通过分层设计和以 Hooks 为中心的逻辑封装,成功地将一个复杂的 WebRTC 应用拆解为一系列清晰、可维护的模块。UI、业务逻辑和底层库之间的界限分明,为未来的功能扩展和维护奠定了坚实的基础。
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# PrivyDrop AI Playbook — 代码地图(中文)
|
||||
|
||||
本地图以“快速定位”为目标,仅给出目录与关键入口文件的简要说明,不包含“常改动点/影响范围”。
|
||||
|
||||
## 前端(Next.js,TypeScript)
|
||||
|
||||
- `frontend/app/` — App Router 路由与页面。
|
||||
|
||||
- `frontend/app/[lang]/page.tsx` — 主页入口,生成元数据和 SEO 结构化数据(JsonLd),支持多语言 canonical 链接。
|
||||
- `frontend/app/[lang]/*/page.tsx` — 静态页面:features(功能特性)、about(关于)、faq(常见问题)、help(帮助)、terms(服务条款)、privacy(隐私政策),均包含多语言 SEO 元数据生成。
|
||||
- `frontend/app/[lang]/blog/page.tsx` — 博客列表页,展示多语言文章列表。
|
||||
- `frontend/app/[lang]/blog/[slug]/page.tsx` — 博客文章详情页,支持 MDX 渲染、目录生成、面包屑导航和 JSON-LD 结构化数据。
|
||||
- `frontend/app/[lang]/blog/tag/[tag]/page.tsx` — 博客标签页,按标签分类展示文章。
|
||||
- `frontend/app/[lang]/layout.tsx` — 全局布局与 Provider 注入,包含 ThemeProvider、Header/Footer,生成组织架构和网站结构化数据。
|
||||
- `frontend/app/[lang]/HomeClient.tsx` — 主客户端组件,组织页面结构(Hero 区域、ClipboardApp、HowItWorks、视频演示、系统架构图、功能特性、FAQ),支持多平台视频链接(YouTube/B 站)。
|
||||
- `frontend/app/api/health/route.ts` — 基础健康检查 API。
|
||||
- `frontend/app/api/health/detailed/route.ts` — 详细健康检查 API。
|
||||
- `frontend/app/sitemap.ts` — 站点地图生成,支持多语言 URL 和博客文章动态收录。
|
||||
- `frontend/middleware.ts` — i18n 与路由中间件。
|
||||
- `frontend/app/config/environment.ts` — 运行时/环境配置(ICE、端点等)。
|
||||
- `frontend/app/config/api.ts` — 后端 API 交互封装。
|
||||
|
||||
- `frontend/components/` — UI,包括协调器与子组件。
|
||||
|
||||
- `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooks(useWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages),处理全局拖拽事件和双标签页(发送/接收)管理。
|
||||
- 体验增强:切到接收端(retrieve)且满足“未在房间、URL 无 roomId、输入为空、存在缓存ID”时自动填充并加入房间(读取 `frontend/lib/roomIdCache.ts`)。
|
||||
- `frontend/components/ClipboardApp/SendTabPanel.tsx` — 发送面板,集成富文本编辑器、文件上传、房间 ID 生成(支持 4 位数字/UUID 两种模式)、分享链接生成。
|
||||
- 体验增强:点击“使用缓存ID”将立即触发加入房间(sender 侧),减少一次手动点击。
|
||||
- `frontend/components/ClipboardApp/RetrieveTabPanel.tsx` — 接收面板,处理房间加入、文件接收、目录选择(File System Access API)、富文本内容显示。
|
||||
- `frontend/components/ClipboardApp/FileListDisplay.tsx` — 文件列表显示组件,支持文件/文件夹分组显示、进度跟踪、多浏览器下载策略(Chrome 自动下载/其他浏览器手动保存)、下载计数统计。
|
||||
- `frontend/components/ClipboardApp/FullScreenDropZone.tsx` — 全屏拖拽提示组件,文件拖拽时的视觉反馈。
|
||||
- `frontend/components/ClipboardApp/*` — 其他子组件:FileUploadHandler(文件上传处理)、ShareCard(二维码分享)、TransferProgress(进度条)、CachedIdActionButton(缓存 ID 操作)、FileTransferButton(文件传输按钮)。
|
||||
- `frontend/components/Editor/` — 富文本编辑器模块,包含 RichTextEditor 主编辑器、工具栏组件(BasicFormatTools/FontTools/AlignmentTools/InsertTools)、SelectMenu 下拉选择、类型定义和编辑器 hooks。
|
||||
- `frontend/components/blog/` — 博客相关组件,包含 TableOfContents(支持中文目录生成和滚动跟踪)、Mermaid 图表渲染、MDXComponents、ArticleListItem 文章列表。
|
||||
- `frontend/components/common/` — 通用组件,包含 clipboard_btn(读写剪贴板按钮)、AutoPopupDialog(自动弹出对话框)、LazyLoadWrapper(懒加载包装器)、YouTubePlayer(YouTube 播放器)。
|
||||
- `frontend/components/web/` — 网站页面组件,包含 Header(响应式导航和多语言支持)、Footer(版权和语言链接)、FAQSection(可配置 FAQ 展示)、HowItWorks(步骤说明和视频演示)、SystemDiagram(系统架构图)、KeyFeatures(功能特性展示)、theme-provider 主题提供者。
|
||||
- `frontend/components/web/ThemeToggle.tsx` — 主题切换按钮(单按钮 Light/Dark 切换),集成于 Header(桌面与移动)。
|
||||
- `frontend/components/seo/JsonLd.tsx` — SEO 结构化数据组件,支持多类型 JSON-LD 数据生成。
|
||||
- `frontend/components/LanguageSwitcher.tsx` — 语言切换器。
|
||||
- `frontend/components/ui/*` — 基础 UI 原子组件(基于 Radix UI 和 shadcn/ui),包含 Button(多变体按钮)、Accordion(手风琴)、Dialog(模态对话框)、Card(卡片)、Tooltip(工具提示)、Select、Input、Textarea、Checkbox、DropdownMenu、Toast 通知系统和 AnimatedButton 动画按钮。
|
||||
|
||||
- `frontend/hooks/` — 业务逻辑中枢(React Hooks)。
|
||||
|
||||
- `frontend/hooks/useWebRTCConnection.ts` — WebRTC 生命周期与编排 API。
|
||||
- `frontend/hooks/useRoomManager.ts` — 房间创建/加入/校验与 UI 状态,支持缓存 ID 重连(≥8 字符自动发送 initiator-online)。
|
||||
- `frontend/hooks/useFileTransferHandler.ts` — 文件/文本负载编排与回调,使用 getState() 修复闭包问题,支持 JSZip 文件夹下载。
|
||||
- `frontend/hooks/useClipboardActions.ts` — 剪贴板操作与状态管理,支持现代 API 和 document.execCommand 降级,处理 HTML/富文本粘贴。
|
||||
- `frontend/hooks/useClipboardAppMessages.ts` — 应用消息处理(shareMessage/retrieveMessage),4 秒自动消失机制。
|
||||
- `frontend/hooks/useLocale.ts` — 国际化语言切换,基于 pathname 解析。
|
||||
- `frontend/hooks/usePageSetup.ts` — 页面配置与 SEO 设置,处理 URL 参数 roomId 自动加入和引荐来源追踪。
|
||||
- `frontend/hooks/useRichTextToPlainText.ts` — 富文本转纯文本工具,处理块级元素换行和文本节点包装。
|
||||
|
||||
- `frontend/lib/` — 核心库与工具。
|
||||
|
||||
- WebRTC 基础与角色
|
||||
- `frontend/lib/webrtc_base.ts` — WebRTC 基础类,提供 Socket.IO 信令、RTCPeerConnection 管理、ICE 候选者队列、双重断开检测重连机制、唤醒锁管理、数据通道发送重试(5 次递增间隔)、优雅断开跟踪(gracefullyDisconnectedPeers Set)和多格式数据类型兼容性支持(ArrayBuffer/Blob/Uint8Array/TypedArray)。
|
||||
- `frontend/lib/webrtc_Initiator.ts` — 发起方实现,处理`ready`/`recipient-ready`事件,创建 RTCPeerConnection 和主动式 DataChannel,发送 offer,处理 answer 响应,支持 256KB 缓冲阈值配置。
|
||||
- `frontend/lib/webrtc_Recipient.ts` — 接收方实现,处理`offer`事件,创建 RTCPeerConnection 和响应式 DataChannel(ondatachannel),生成并发送 answer,处理`initiator-online`重连信号和现有连接清理。
|
||||
- `frontend/lib/webrtcService.ts` — WebRTC 服务单例封装(跨路由常驻),管理 sender/receiver 实例,提供统一业务接口,处理连接状态变更、数据广播、文件请求和连接断开清理。
|
||||
- 发送(sender)
|
||||
- `frontend/lib/fileSender.ts` — 发送端向后兼容包装层,内部使用 FileTransferOrchestrator 提供统一服务。
|
||||
- `frontend/lib/transfer/FileTransferOrchestrator.ts` — 发送端主编排器,集成所有组件管理文件传输生命周期。
|
||||
- `frontend/lib/transfer/StreamingFileReader.ts` — 高性能流式文件读取器,采用 32MB 批次+64KB 网络块的双层缓冲架构。
|
||||
- `frontend/lib/transfer/NetworkTransmitter.ts` — 网络传输器,使用 WebRTC 原生背压控制,支持嵌入元数据分片发送。
|
||||
- `frontend/lib/transfer/StateManager.ts` — 状态管理中心,跟踪 peer 状态、待发送文件、文件夹元数据。
|
||||
- `frontend/lib/transfer/ProgressTracker.ts` — 进度跟踪器,处理文件/文件夹进度计算、速度统计和回调触发。
|
||||
- `frontend/lib/transfer/MessageHandler.ts` — 消息处理器,负责 WebRTC 消息路由(fileRequest/fileReceiveComplete/folderReceiveComplete)。
|
||||
- `frontend/lib/transfer/TransferConfig.ts` — 传输配置管理,定义文件读取 4MB 分片、32MB 批次、64KB 网络发送块。
|
||||
- 接收(receiver)
|
||||
- `frontend/lib/fileReceiver.ts` — 接收端向后兼容包装层,内部使用 FileReceiveOrchestrator 提供统一服务。
|
||||
- `frontend/lib/receive/FileReceiveOrchestrator.ts` — 接收端主编排器,集成所有组件管理文件接收生命周期,支持断点续传和磁盘流式写入。
|
||||
- `frontend/lib/receive/ReceptionStateManager.ts` — 状态管理中心,管理文件元数据、活跃接收状态、文件夹进度、保存类型配置。
|
||||
- `frontend/lib/receive/ChunkProcessor.ts` — 分片处理器,处理多种数据格式转换、嵌入元数据解析、分片验证和索引映射。
|
||||
- `frontend/lib/receive/StreamingFileWriter.ts` — 流式文件写入器,包含 SequencedDiskWriter 严格顺序写入机制,支持大文件磁盘流式写入。
|
||||
- `frontend/lib/receive/FileAssembler.ts` — 内存文件组装器,处理小块文件的内存重组、完整性校验和文件对象创建。
|
||||
- `frontend/lib/receive/MessageProcessor.ts` — 消息处理器,负责 WebRTC 消息路由(fileMeta/stringMetadata/fileRequest/folderReceiveComplete)。
|
||||
- `frontend/lib/receive/ProgressReporter.ts` — 进度报告器,处理文件/文件夹进度计算、速度统计和节流回调。
|
||||
- `frontend/lib/receive/ReceptionConfig.ts` — 接收配置管理,定义大文件阈值 1GB、64KB 分片、缓冲区大小和调试开关。
|
||||
- 工具与辅助
|
||||
- `frontend/lib/fileReceiver.ts`、`frontend/lib/fileUtils.ts`、`frontend/lib/speedCalculator.ts`、`frontend/lib/utils.ts` — 基础工具。
|
||||
- `frontend/lib/roomIdCache.ts` — 房间 ID 缓存管理。
|
||||
- `frontend/lib/wakeLockManager.tsx` — 屏幕唤醒锁管理(移动端优化)。
|
||||
- `frontend/lib/utils/ChunkRangeCalculator.ts` — 文件分片范围计算。
|
||||
- `frontend/lib/browserUtils.ts` — 浏览器兼容性工具。
|
||||
- `frontend/lib/tracking.ts` — 用户行为追踪。
|
||||
- `frontend/lib/dictionary.ts`、`frontend/lib/mdx-config.ts`、`frontend/lib/blog.ts` — i18n/内容与 SEO 辅助。
|
||||
|
||||
- `frontend/stores/` — 共享应用状态(Zustand)。
|
||||
|
||||
- `frontend/stores/fileTransferStore.ts` — 传输进度/状态的唯一事实来源(Zustand 单例,跨路由保持)。
|
||||
|
||||
- `frontend/types/`、`frontend/constants/` — 类型定义与常量。
|
||||
|
||||
- `frontend/types/global.d.ts` — 全局类型定义(lodash 模块、FileSystemDirectoryHandle 接口)。
|
||||
- `frontend/types/messages.ts` — 多语言消息与 UI 内容类型定义(Meta、Text、Messages 等国际化结构)。
|
||||
- `frontend/types/webrtc.ts` — WebRTC 传输协议类型(文件元数据、分片结构、状态机接口)。
|
||||
- `frontend/constants/messages/` — 多语言消息文件(7 种语言:en、zh、de、es、fr、ja、ko)。
|
||||
- `frontend/constants/i18n-config.ts` — 国际化配置(默认语言、支持语言列表、显示名称映射)。
|
||||
|
||||
- `frontend/content/` — 内容资源。
|
||||
|
||||
- `frontend/content/blog/` — 博客文章(MDX 格式,多语言),包含开源发布、WebRTC 文件传输、断点续传等主题文章。
|
||||
- `frontend/lib/blog.ts` — 博客工具函数,支持多语言文章读取、frontmatter 解析、标签提取和内容验证。
|
||||
|
||||
- **配置与构建**
|
||||
- `frontend/package.json`、`frontend/tsconfig.json`、`frontend/tailwind.config.ts` — 项目配置。
|
||||
- `frontend/next.config.mjs`、`frontend/postcss.config.mjs`、`frontend/components.json` — Next.js 与组件配置。
|
||||
- `frontend/.eslintrc.json` — 代码检查配置。
|
||||
- `frontend/Dockerfile`、`frontend/health-check.js` — Docker 部署与健康检查。
|
||||
|
||||
## 后端(Express,Socket.IO,Redis)
|
||||
|
||||
- `backend/src/server.ts` — 启动入口:Express + Socket.IO 初始化与监听。
|
||||
- `backend/src/config/env.ts`、`backend/src/config/server.ts` — 环境与服务配置。
|
||||
- `backend/src/config/env.ts` — 环境变量配置与验证,包含端口、CORS、Redis 连接设置,支持开发/生产环境自动加载对应.env 文件。
|
||||
- `backend/src/config/server.ts` — CORS 配置,区分开发/生产环境,支持多域名配置和 LAN 地址正则匹配。
|
||||
- `backend/src/routes/api.ts` — REST:房间创建/校验、追踪、调试日志。
|
||||
- `backend/src/routes/health.ts` — 健康检查。
|
||||
- `backend/src/socket/handlers.ts` — 信令事件:`join`、`initiator-online`、`recipient-ready`、`offer`、`answer`、`ice-candidate`。
|
||||
- `backend/src/services/redis.ts` — Redis 客户端。
|
||||
- `backend/src/services/room.ts` — 房间/成员存储与辅助。
|
||||
- `backend/src/services/rateLimit.ts` — 基于 Redis 有序集的 IP 限流。
|
||||
- `backend/src/types/room.ts`、`backend/src/types/socket.ts` — 类型定义与接口。
|
||||
|
||||
- `backend/src/types/socket.ts` — Socket.IO 相关类型,包含 JoinData 房间加入数据、SignalingData WebRTC 信令数据(offer/answer/candidate)、InitiatorData 发起方数据、RecipientData 接收方数据。
|
||||
- `backend/src/types/room.ts` — 房间相关类型,包含 RoomInfo 房间信息(创建时间)、ReferrerTrack 来源追踪数据、LogMessage 日志消息结构。
|
||||
|
||||
- **后端配置与脚本**
|
||||
- `backend/package.json`、`backend/tsconfig.json` — 项目配置。
|
||||
- `backend/Dockerfile`、`backend/.dockerignore` — Docker 配置。
|
||||
- `backend/health-check.js` — 健康检查脚本。
|
||||
- `backend/scripts/export-tracking-data.js` — 数据导出脚本。
|
||||
- `backend/docker/` — Docker 相关配置与脚本(包含 Nginx、TURN 服务器配置)。
|
||||
|
||||
## 部署与运维
|
||||
|
||||
- **根目录配置**
|
||||
|
||||
- `docker-compose.yml`、`ecosystem.config.js` — Docker Compose 与 PM2 配置。
|
||||
- `build-and-deploy.sh`、`deploy.sh` — 构建与部署脚本。
|
||||
- `deploy.config_prod`、`deploy.config_test` — 生产与测试环境配置。
|
||||
|
||||
- **Docker 基础设施**
|
||||
|
||||
- `docker/nginx/` — Nginx 反向代理配置。
|
||||
- `docker/scripts/` — 部署相关脚本(环境检测、配置生成、部署测试)。
|
||||
- `docker/ssl/` — SSL 证书目录。
|
||||
- `docker/coturn/` — TURN 服务器配置。
|
||||
- `docker/letsencrypt-www/` — Let's Encrypt 配置。
|
||||
|
||||
- **构建与文档**
|
||||
- `build/` — 请忽略这个临时目录。
|
||||
- `test-health-apis.sh` — 健康 API 测试脚本。
|
||||
- `README.md`、`README.zh-CN.md`、`ROADMAP.md`、`ROADMAP.zh-CN.md` — 项目文档。
|
||||
@@ -0,0 +1,149 @@
|
||||
# PrivyDrop AI Playbook — 协作规则(中文)
|
||||
|
||||
本规则面向“人类开发者 + AI 助手”的协作,确保在保持隐私立场与技术基线的前提下,高效而可控地演进代码。与目录索引(index.zh-CN.md)和流程地图(flows.zh-CN.md)互补:它约束“如何做”,不复述“做什么”。
|
||||
|
||||
- 适用范围:本仓库全部代码与文档
|
||||
- 读者对象:人类开发者、AI 助手、评审者
|
||||
- 变更原则:最佳实践优先、一次只解决一类问题、可回滚、可验证
|
||||
|
||||
## 一、协作原则
|
||||
|
||||
- Best Practices 优先:选用经过验证且与现有栈一致的方案,避免“自造轮子”。
|
||||
- 单一主题:每个变更聚焦一个目标,避免“顺手修复”无关问题。
|
||||
- 隐私立场:严禁引入服务器中转文件数据的实现或建议;后端仅做信令与房间协调。
|
||||
- 小步快跑:小 PR、易回滚,优先最小可行改动。
|
||||
- 可追溯:提交信息、PR 描述、代码注释清晰、可复现。
|
||||
|
||||
## 二、计划先行(强约束)
|
||||
|
||||
任何实现前,必须先提交“变更计划”,经同意后再实施。计划应包含:目标、影响范围与文件列表、方案概述、风险与缓解、验收标准、回滚策略、需更新的文档、验证方式。
|
||||
|
||||
推荐模板见“模板”章节。实施前需阅读并引用:
|
||||
|
||||
- docs/ai-playbook/index.zh-CN.md
|
||||
- docs/ai-playbook/code-map.zh-CN.md
|
||||
- docs/ai-playbook/flows.zh-CN.md
|
||||
|
||||
## 三、语言与注释
|
||||
|
||||
- 沟通语言:与项目负责人沟通一律使用中文(简体)。
|
||||
- 代码注释、导出符号命名、提交信息、PR 标题/描述一律英文。
|
||||
- 用户/市场文档可中英双语;本协作规则为中文。
|
||||
- 导出函数、复杂流程、公共类型使用 TSDoc/JSDoc(英文),保证 API 可读性。
|
||||
|
||||
## 四、Next.js(前端)约定
|
||||
|
||||
- App Router 默认 Server Components;仅在确需交互时使用 "use client"。
|
||||
- 复用现有 UI(Tailwind + shadcn/ui + Radix);未经批准不引入新组件库。
|
||||
- i18n:所有可见文案走字典与 `frontend/app/[lang]` 路由,不在组件内硬编码。
|
||||
- 命名与文件:
|
||||
- 组件:PascalCase 文件与导出(ExampleCard.tsx)
|
||||
- Hooks:camelCase 文件,导出以 use\* 开头(useSomething.ts)
|
||||
- 类型/常量集中维护,避免循环依赖
|
||||
- SEO:使用 Next Metadata 与 `frontend/components/seo/JsonLd.tsx`;页面需补 canonical、多语言链接。
|
||||
- 性能与可访问性:按需动态导入重组件;确保 aria/焦点管理基本可用。
|
||||
|
||||
## 五、TypeScript 与风格
|
||||
|
||||
- 类型严格,避免 any;必要时用 unknown 并显式收窄;导出函数显式返回类型。
|
||||
- 遵循现有 ESLint/Prettier 与路径别名(`@/...`);不引入新格式化器。
|
||||
- 函数小而清晰;复杂逻辑下沉到 service/util,组件只消费不变更状态。
|
||||
- 不使用一字母变量名;避免魔法数,集中到常量。
|
||||
|
||||
## 六、WebRTC/传输“护栏”(不得突破)
|
||||
|
||||
- 保持既定策略:32MB 批次 + 64KB 网络块;DataChannel bufferedAmountLowThreshold 与 maxBuffer 策略不随意更改。
|
||||
- 断点续传、严格顺序写入、多格式兼容是默认能力,禁止降级或移除。
|
||||
- 信令与消息名(offer/answer/ice-candidate 等)保持兼容;如需破坏性变更,必须走“必须请示”流程。
|
||||
- 重连与队列处理(ICE 候选缓存、背压、发送重试)策略保持一致,变更需风险评估与充分验证。
|
||||
- 严禁将文件内容(任何形式)发往服务器或第三方服务。
|
||||
|
||||
## 七、后端约束(信令服务)
|
||||
|
||||
- 仅负责信令与房间管理;不落地用户文件数据;日志中不得包含敏感内容或原始 payload。
|
||||
- 速率与滥用防护保留;如需扩展接口,必须保证向后兼容或提供迁移策略。
|
||||
|
||||
## 八、依赖与安全
|
||||
|
||||
- 新依赖需在计划中论证:体积(含 ESM/SSR 兼容性)、维护健康度、许可、替代方案、安全影响。
|
||||
- 不引入遥测/埋点;不将敏感数据写入日志;最小权限原则。
|
||||
- 配置通过环境变量注入;严禁在仓库中硬编码密钥或服务端点。
|
||||
|
||||
## 九、文档同步更新
|
||||
|
||||
- 代码改动若影响流程、接口或关键文件入口,须同步更新:
|
||||
- docs/ai-playbook/flows.zh-CN.md
|
||||
- docs/ai-playbook/code-map.zh-CN.md
|
||||
- PR 必须列出“受影响文档”,避免 AI Playbook 过时;索引页(index.zh-CN.md)保持简洁,仅新增链接时更新。
|
||||
|
||||
## 十、验证与回归
|
||||
|
||||
- 前端:能构建通过(next build);关键路径手测说明(至少:创建/加入房间、单/多文件、文件夹、大文件、断点续传、双浏览器互传、i18n 路由与 SEO 元数据)。
|
||||
- 后端:Socket.IO 基本流程可用。
|
||||
- 回归清单:重连流程、下载计数与状态清理、Store 单一数据源约束、浏览器兼容(Chromium/Firefox)。
|
||||
|
||||
## 十一、必须请示(需先获批)
|
||||
|
||||
- 协议/消息名/公共 API/存储格式的破坏性变更。
|
||||
- 影响隐私立场或跨边界的架构调整(如任何形式的中转或持久化)。
|
||||
- 引入新依赖、新基础设施或大规模重构。
|
||||
- 修改传输“护栏”参数(分片、背压、重试等)。
|
||||
|
||||
## 十二、常见误区
|
||||
|
||||
- 组件内直改全局状态(违背单向数据流)。
|
||||
- 只改代码不更文档,导致 Playbook 过期。
|
||||
- 使用 any 绕过类型与边界检查。
|
||||
- 将 UI 文案硬编码在组件内,绕过字典/i18n。
|
||||
- 擅自调整 WebRTC 关键参数,导致隐性性能回退或兼容性问题。
|
||||
|
||||
## 十三、模板
|
||||
|
||||
变更计划模板
|
||||
|
||||
```
|
||||
Title: <简明标题>
|
||||
|
||||
Goals
|
||||
- <预期达成的目标>
|
||||
|
||||
Scope / Files
|
||||
- <将修改与新增的文件路径清单 + 原因>
|
||||
|
||||
Approach
|
||||
- <实现思路与关键设计点>
|
||||
|
||||
Risks & Mitigations
|
||||
- <主要风险> → <缓解策略>
|
||||
|
||||
Acceptance Criteria
|
||||
- <可验证的验收项>
|
||||
|
||||
Rollback
|
||||
- <如何快速回滚>
|
||||
|
||||
Docs to Update
|
||||
- code-map.zh-CN.md / flows.zh-CN.md / README(.zh-CN).md / others?
|
||||
|
||||
Validation
|
||||
- Build: next build / backend health
|
||||
- Manual: <列出关键用例>
|
||||
```
|
||||
|
||||
PR 校验清单
|
||||
|
||||
```
|
||||
- [ ] 仅包含单一主题改动
|
||||
- [ ] 代码注释与提交信息为英文
|
||||
- [ ] 未引入未批准的依赖/组件库
|
||||
- [ ] i18n 与 SEO 按约定接入(如适用)
|
||||
- [ ] 传输护栏未被破坏(或已获批且有验证)
|
||||
- [ ] flows / code-map 文档已同步
|
||||
- [ ] 附带验证说明与回归清单
|
||||
```
|
||||
|
||||
## 十四、引用与快速入口
|
||||
|
||||
- 索引与上下文:docs/ai-playbook/index.zh-CN.md
|
||||
- 代码地图:docs/ai-playbook/code-map.zh-CN.md
|
||||
- 关键流程:docs/ai-playbook/flows.zh-CN.md
|
||||
@@ -0,0 +1,694 @@
|
||||
# PrivyDrop AI Playbook — 流程(含微方案模板,中文)
|
||||
|
||||
本文汇总 P2P 传输与信令重连的关键流程与消息序列,并给出简明的调试要点与“微方案模板”。用于在改动前快速对齐阶段、事件与入口文件。
|
||||
|
||||
## 1)文件传输(单文件)
|
||||
|
||||
序列(通过 DataChannel,发送端 ↔ 接收端):
|
||||
|
||||
1. 发送端 → `fileMetadata`(id、name、size、type、fullName、folderName)。
|
||||
2. 接收端 → `fileRequest`(确认元信息;支持 offset 续传)。
|
||||
3. 发送端 → 分片流(高性能双层缓冲架构):
|
||||
- StreamingFileReader 使用 32MB 批次读取 + 64KB 网络块发送
|
||||
- NetworkTransmitter 使用 WebRTC 原生背压控制(bufferedAmountLowThreshold)
|
||||
- 发送时嵌入元数据(chunkIndex、totalChunks、fileOffset、fileId)
|
||||
4. 接收端 → 完整性检查与组装(严格顺序写入或内存组装,支持断点续传)。
|
||||
5. 接收端 → `fileReceiveComplete`(成功回执,包含 receivedSize)。
|
||||
6. 发送端 → MessageHandler 触发 100% 进度回调,清理发送状态。
|
||||
|
||||
发送侧详细流程:
|
||||
|
||||
1. FileTransferOrchestrator.sendFileMeta() → StateManager 记录文件夹文件大小
|
||||
2. 接收 fileRequest → FileTransferOrchestrator.handleFileRequest()
|
||||
3. 初始化 StreamingFileReader(支持 startOffset 续传)
|
||||
4. processSendQueue() 循环:
|
||||
- getNextNetworkChunk() 获取 64KB 块(批次内高效切片)
|
||||
- NetworkTransmitter.sendEmbeddedChunk() 背压控制发送
|
||||
- ProgressTracker.updateFileProgress() 更新进度和速度
|
||||
5. 等待 fileReceiveComplete 确认,清理 isSending 状态
|
||||
|
||||
入口:
|
||||
|
||||
- 发送侧:`frontend/lib/fileSender.ts`(兼容层)→ `frontend/lib/transfer/FileTransferOrchestrator.ts`(主编排器)
|
||||
- 关键组件:StreamingFileReader(高性能读取)、NetworkTransmitter(背压发送)、StateManager(状态管理)、ProgressTracker(进度计算)
|
||||
|
||||
接收侧详细流程:
|
||||
|
||||
1. MessageProcessor.handleFileMetadata() → ReceptionStateManager 记录文件元数据
|
||||
2. FileReceiveOrchestrator.requestFile() → 检查断点续传(getPartialFileSize)
|
||||
3. 初始化接收:计算期望分片数,根据文件大小选择存储方式(内存 vs 磁盘)
|
||||
4. 发送 fileRequest(带 offset 参数)→ 等待发送端开始传输
|
||||
5. handleBinaryChunkData() 循环:
|
||||
- ChunkProcessor.convertToArrayBuffer() 处理多种数据格式(Blob/Uint8Array/ArrayBuffer)
|
||||
- ChunkProcessor.parseEmbeddedChunkPacket() 解析嵌入元数据包格式
|
||||
- ChunkProcessor.validateChunk() 验证 fileId、chunkIndex、chunkSize
|
||||
- 存储分片到 chunks 数组(或通过 SequencedDiskWriter 顺序写入磁盘)
|
||||
- ProgressReporter.updateFileProgress() 节流更新进度(100ms 间隔)
|
||||
6. 自动完成检测:checkAndAutoFinalize() 验证分片完整性
|
||||
7. 根据存储方式选择最终化:
|
||||
- 大文件/磁盘存储:StreamingFileWriter.finalizeWrite()
|
||||
- 小文件/内存存储:FileAssembler.assembleFileFromChunks()
|
||||
8. 发送 fileReceiveComplete 确认,包含 receivedSize 和 receivedChunks
|
||||
|
||||
入口:
|
||||
|
||||
- 发送侧:`frontend/lib/fileSender.ts`(兼容层)→ `frontend/lib/transfer/FileTransferOrchestrator.ts`(主编排器)
|
||||
- 接收侧:`frontend/lib/fileReceiver.ts`(兼容层)→ `frontend/lib/receive/FileReceiveOrchestrator.ts`(主编排器)
|
||||
- 关键组件:StreamingFileReader(高性能读取)、NetworkTransmitter(背压发送)、ChunkProcessor(格式处理)、StreamingFileWriter(磁盘写入)、FileAssembler(内存组装)
|
||||
|
||||
备注:
|
||||
|
||||
- **发送侧**:双层缓冲架构(32MB 批次+64KB 网络块),WebRTC 原生背压控制,支持断点续传
|
||||
- **接收侧**:严格顺序写入机制(SequencedDiskWriter),支持多种数据格式转换,智能存储选择(≥1GB 文件自动磁盘存储)
|
||||
- **兼容性处理**:ChunkProcessor 支持 Blob/Uint8Array/ArrayBuffer 多种格式,解决 Firefox 兼容性问题
|
||||
- **进度节流**:ProgressReporter 使用不同频率更新(文件 100ms,文件夹 200ms),避免 UI 过载
|
||||
- **断点续传**:通过 getPartialFileSize() 检查本地部分文件,fileRequest.offset 参数指定续传位置
|
||||
- **调试支持**:ReceptionConfig 提供详细的分片日志和进度日志,便于问题排查
|
||||
|
||||
## 2)文件传输(文件夹)
|
||||
|
||||
序列(对文件逐个进行单文件流程):
|
||||
|
||||
1. 发送端 → 发送文件夹内全部文件的 `fileMetadata`。
|
||||
2. 接收端 → `folderRequest`(确认开始批量传输)。
|
||||
3. 每个文件:按“单文件流程”执行,但单个文件完成时不标记全局 100%。
|
||||
4. 接收端 → 所有文件完成后发送 `folderReceiveComplete`。
|
||||
5. 发送端 → 将文件夹整体进度标记为 100%(触发最终回调)。
|
||||
|
||||
## 3)信令与重连(Socket.IO)
|
||||
|
||||
高层序列:
|
||||
|
||||
1. 客户端 → REST:创建或获取 `roomId`(`backend/src/routes/api.ts`)。
|
||||
2. 客户端 → Socket.IO:`join` 房间(后端校验并绑定 socket 到房间)。
|
||||
3. 在房间内进行在线状态与重连协作:
|
||||
- 发起方 → `initiator-online`(上线/就绪,通知对端可重建连接)。
|
||||
- 接收方 → `recipient-ready`(表示就绪;发起方可发起 offer)。
|
||||
4. WebRTC 协商转发:
|
||||
- `offer` → 后端 → 转发给对端。
|
||||
- `answer` → 后端 → 转发给对端。
|
||||
- `ice-candidate` → 后端 → 转发给对端。
|
||||
|
||||
重连机制细节(移动端网络切换支持):
|
||||
|
||||
- **双重断开检测**:Socket.IO 断开触发 `disconnect` 事件 → 标记 `isSocketDisconnected = true`;P2P 连接断开触发 `disconnected` 状态 → 标记 `isPeerDisconnected = true`,自动调用 `cleanupExistingConnection()` 清理资源
|
||||
- **重连触发条件**:仅当 socket 和 P2P 都断开时才启动重连 → `attemptReconnection()`,防止重复重连;使用 `reconnectionInProgress` 标志防止并发重连
|
||||
- **状态恢复机制**:重连时调用 `joinRoom(roomId, isInitiator, sendInitiatorOnline)` 恢复状态,发送方自动发送 `initiator-online` 信号,接收方响应 `recipient-ready`
|
||||
- **ICE 候选者队列机制**:连接未就绪时缓存候选者到 `iceCandidatesQueue` Map,连接就绪后批量处理;支持候选者失效时的重新入队和连接状态验证
|
||||
- **唤醒锁管理**:连接建立时通过 `WakeLockManager` 请求屏幕唤醒锁,连接断开时释放,优化移动端传输稳定性
|
||||
- **优雅断开跟踪**:`gracefullyDisconnectedPeers` Set 跟踪正常断开的 peer,发送重试时跳过这些 peer,避免不必要的重试
|
||||
- **数据通道发送重试**:5 次重试机制,间隔从 100ms 递增到 1000ms,支持 `gracefullyDisconnectedPeers` 检测跳过重试
|
||||
|
||||
**后端信令与房间管理**:
|
||||
|
||||
Socket.IO 事件处理流程:
|
||||
|
||||
1. **join 事件**:IP 限流检查 → 房间存在性验证 → socket-room 绑定 → 成功响应 → 广播 `ready` 通知新用户加入
|
||||
2. **重连状态同步**:发送方重连时发送 `initiator-online` 信号,接收方响应 `recipient-ready` 确认就绪状态
|
||||
3. **信令转发**:offer/answer/ice-candidate 直接通过 `socket.to(peerId).emit()` 转发给目标客户端,包含 from 字段标识发送者
|
||||
4. **断开清理**:广播 `peer-disconnected` → 解绑 socket-room 关系 → 空房间 15 分钟后删除
|
||||
|
||||
**房间管理机制**:
|
||||
|
||||
- Redis 数据结构:
|
||||
- `room:<roomId>` (Hash): 存储房间创建时间
|
||||
- `room:<roomId>:sockets` (Set): 管理房间内 socket 连接
|
||||
- `socket:<socketId>` (String): 存储 socket 对应的 roomId
|
||||
- ID 生成策略:优先 4 位数字 ID,冲突时切换到 4 位字母数字 ID
|
||||
- 幂等设计:长 ID(≥8 字符)支持重连时的房间复用
|
||||
- TTL 管理:24 小时过期,活动时自动刷新
|
||||
|
||||
**限流保护**:
|
||||
|
||||
- 基于 Redis Sorted Set 实现 IP 限流
|
||||
- 5 秒时间窗口最多允许 2 次请求
|
||||
- 使用 pipeline 确保原子性操作
|
||||
|
||||
入口:
|
||||
|
||||
- 前端:`frontend/hooks/useWebRTCConnection.ts`、`frontend/lib/webrtc_base.ts`、`frontend/lib/webrtc_Initiator.ts`、`frontend/lib/webrtc_Recipient.ts`。
|
||||
- 后端:`backend/src/socket/handlers.ts`(全部信令事件)、`backend/src/services/room.ts`、`backend/src/routes/api.ts`。
|
||||
|
||||
## 4)DataChannel 消息与约束(概览)
|
||||
|
||||
- 消息(示例命名):`fileMetadata`、`fileRequest`、`chunk`、`fileReceiveComplete`、`folderRequest`、`folderReceiveComplete`,以及可能的流控/保活。
|
||||
- 核心字段:文件/文件夹 id、索引/范围、大小、名称、可选校验信息。
|
||||
- 关键约束:
|
||||
- 分片大小:按浏览器/网络选择安全范围;注意通道缓冲阈值。
|
||||
- 背压:检查 `RTCDataChannel.bufferedAmount` 并按需节流。
|
||||
- 完成:仅在收到 `fileReceiveComplete`/`folderReceiveComplete` 后标记 100%。
|
||||
- 续传:`fileRequest` 可设计为带 offset/range 以支持续传。
|
||||
|
||||
## 5)调试要点(凝练自历史经验)
|
||||
|
||||
- 下载竞争/重复计数:以 `frontend/stores/fileTransferStore.ts` 为单一事实来源;在 Store 层提供清理 API(如 `clearSendProgress`、`clearReceiveProgress`),避免组件本地删除对象导致重复计数。
|
||||
- 接收方重连与房间状态:正确的状态重置;UI 严格来源于 Store;离开/重进需清理相关状态;遵循 `initiator-online`/`recipient-ready` 的时序再发起 offer;重连后校验房间成员关系。
|
||||
- 缓存 roomId 的重连:若存在缓存 `roomId`,确保依赖在线状态同步(`initiator-online`/`recipient-ready`)触发重新协商;后端需保证 socket↔room 映射在断开/重连路径上被正确清理与恢复。
|
||||
- 多次传输计数:避免过度“去重”掩盖真实的二次下载;依赖正确的状态清理。
|
||||
- 数据流原则:单向数据流(Store → Hooks → Components);Hooks 做适配,组件只消费不修改。
|
||||
- **实用调试策略**:
|
||||
- 为连接状态变化与 Store 更新添加结构化日志;遇到时序/竞态可用 `setTimeout(..., 0)` 调整更新顺序
|
||||
- DataChannel 发送重试机制:`sendToPeer()` 支持 5 次重试,间隔 100ms→1000ms 递增;优雅断开的 peer 跳过重试
|
||||
- WebRTC 数据类型兼容性:支持 `ArrayBuffer`/`Blob`/`Uint8Array`/`TypedArray` 多种格式,解决 Firefox 兼容性问题
|
||||
- 连接状态监控:`connectionState` 变化时触发相应的处理逻辑(connected/disconnected/failed/closed)
|
||||
- 背压控制:DataChannel 设置 `bufferedAmountLowThreshold = 256KB`,发送时检查 `bufferedAmount` 状态
|
||||
|
||||
## 6)前端组件系统与业务中枢协作流程
|
||||
|
||||
### 组件架构层级
|
||||
|
||||
```
|
||||
App Router (page.tsx/layout.tsx)
|
||||
↓
|
||||
HomeClient (页面布局与SEO)
|
||||
↓
|
||||
ClipboardApp (顶层UI协调器)
|
||||
↓
|
||||
SendTabPanel/RetrieveTabPanel (功能面板)
|
||||
↓
|
||||
业务中枢 Hooks (状态管理与业务逻辑)
|
||||
↓
|
||||
Core Services (webrtcService) + Store (fileTransferStore)
|
||||
```
|
||||
|
||||
### ClipboardApp 顶层协调器模式
|
||||
|
||||
**核心职责**:
|
||||
|
||||
- 集成 5 个关键业务 hooks:useWebRTCConnection、useFileTransferHandler、useRoomManager、usePageSetup、useClipboardAppMessages
|
||||
- 全局拖拽事件处理:dragenter/dragleave/dragover/drop,支持多文件和文件夹树遍历
|
||||
- 双标签页状态管理:发送/接收面板切换,通过 activeTab 控制
|
||||
- 统一消息系统:shareMessage/retrieveMessage 4 秒自动消失机制
|
||||
|
||||
### Hook 层级与职责分离
|
||||
|
||||
**useWebRTCConnection**(状态桥梁):
|
||||
|
||||
- 计算全局传输状态(isAnyFileTransferring)
|
||||
- 暴露 webrtcService 方法(broadcastDataToAllPeers、requestFile、requestFolder)
|
||||
- 提供连接重置方法(resetSenderConnection、resetReceiverConnection)
|
||||
|
||||
**useFileTransferHandler**(文件与内容管理):
|
||||
|
||||
- 文件操作:addFilesToSend(去重)、removeFileToSend
|
||||
- 下载功能:handleDownloadFile(支持文件夹压缩下载)
|
||||
- 关键修复:使用 `useFileTransferStore.getState()` 获取最新状态,避免闭包问题
|
||||
- 重试机制:最大 3 次重试,50ms 间隔,详细错误日志
|
||||
|
||||
**useRoomManager**(房间生命周期管理):
|
||||
|
||||
- 房间操作:joinRoom(支持缓存 ID 重连)、processRoomIdInput(750ms 防抖)
|
||||
- 离开保护:传输中确认提示(isAnyFileTransferring 检查)
|
||||
- 状态文本:动态更新房间状态文本
|
||||
- 链接生成:自动生成分享链接
|
||||
|
||||
**usePageSetup**(页面初始化):
|
||||
|
||||
- 国际化消息加载与错误处理
|
||||
- URL 参数处理:roomId 自动提取并触发加入房间(200ms 延迟确保 DOM 就绪)
|
||||
- 引荐来源追踪(trackReferrer)
|
||||
|
||||
**useClipboardAppMessages**(消息管理):
|
||||
|
||||
- 分离式消息状态:shareMessage(发送相关)和 retrieveMessage(接收相关)
|
||||
- 统一消息显示接口:putMessageInMs(message, isShareEnd, displayTimeMs)
|
||||
- 自动清理机制:4 秒后自动清空消息状态
|
||||
|
||||
### 面板组件特化设计
|
||||
|
||||
**SendTabPanel 发送面板**:
|
||||
|
||||
- 房间 ID 双模式生成:4 位数字(后端 API 生成)和 UUID(前端 crypto API)
|
||||
- 富文本编辑器集成(动态导入,SSR 禁用)
|
||||
- 文件上传处理和文件列表管理
|
||||
- 分享链接生成与二维码显示
|
||||
|
||||
**RetrieveTabPanel 接收面板**:
|
||||
|
||||
- File System Access API 集成:目录选择和直接保存到磁盘
|
||||
- 富文本内容渲染(dangerouslySetInnerHTML)
|
||||
- 文件请求和下载状态管理
|
||||
- 保存位置选择和大文件/文件夹提示
|
||||
|
||||
**FileListDisplay 文件列表**:
|
||||
|
||||
- 智能文件/文件夹分组和统计显示
|
||||
- 多浏览器下载策略:Chrome 自动下载,其他浏览器手动保存提示
|
||||
- 下载计数统计和传输进度跟踪
|
||||
- 断点续传和存储方式显示(内存/磁盘)
|
||||
|
||||
### 关键用户体验优化
|
||||
|
||||
1. **下载状态闭包修复**:`useFileTransferHandler.ts:110` 使用 `useFileTransferStore.getState()` 获取最新状态
|
||||
2. **房间 ID 输入防抖**:`useRoomManager.ts:247` 使用 lodash debounce 750ms 延迟验证
|
||||
3. **传输中离开保护**:`useRoomManager.ts:164,218` 检查 `isAnyFileTransferring` 状态并显示确认对话框
|
||||
4. **缓存 ID 重连**:`useRoomManager.ts:91` 检测长 ID(≥8 字符)自动发送 `initiator-online`
|
||||
5. **文件夹压缩下载**:`useFileTransferHandler.ts:89` 使用 JSZip 动态创建 ZIP 文件
|
||||
6. **全局拖拽优化**:ClipboardApp 使用 dragCounter 防止拖拽状态误判,支持 webkitGetAsEntry 文件树遍历
|
||||
7. **剪贴板兼容性**:useClipboardActions 支持现代 navigator.clipboard API 和 document.execCommand 降级方案
|
||||
8. **富文本安全处理**:useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素
|
||||
9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`(Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。
|
||||
10. **切到接收端自动加入(缓存ID)**:当用户切换到接收端、未在房间、URL 无 `roomId`、输入框为空且本地存在缓存 ID 时,自动填充并直接调用加入房间以提升体验。入口:`frontend/components/ClipboardApp.tsx`(监听 `activeTab` 变化,读取 `frontend/lib/roomIdCache.ts`)。
|
||||
11. **发送端“使用缓存ID”即刻加入**:发送端在 `SendTabPanel` 点击“使用缓存ID”后会立即调用加入房间(而非仅填充输入框)。入口:`frontend/components/ClipboardApp/CachedIdActionButton.tsx`(`onUseCached` 回调)+ `frontend/components/ClipboardApp/SendTabPanel.tsx`。
|
||||
12. **深色主题切换**:提供单按钮 Light/Dark 切换,入口:`frontend/components/web/ThemeToggle.tsx`;集成位置:`frontend/components/web/Header.tsx`(桌面与移动);局部样式从硬编码颜色迁移为设计令牌(例如接收面板使用 `bg-card text-card-foreground`)。
|
||||
|
||||
### 前端组件架构特化
|
||||
|
||||
**富文本编辑器模块**:
|
||||
|
||||
- **RichTextEditor**:主编辑器组件,支持 contentEditable、图片粘贴、格式化工具、SSR 禁用
|
||||
- **工具栏组件分离**:BasicFormatTools(粗体/斜体/下划线)、FontTools(字体/大小/颜色)、AlignmentTools(对齐)、InsertTools(链接/图片/代码块)
|
||||
- **类型安全设计**:完整的 TypeScript 类型定义(FormatType、AlignmentType、FontStyleType、CustomClipboardEvent)
|
||||
- **编辑器 Hooks**:useEditorCommands(命令执行)、useSelection(选择管理)、useStyleManagement(样式管理)
|
||||
|
||||
**网站页面组件设计**:
|
||||
|
||||
- **Header 响应式导航**:桌面端水平导航+移动端汉堡菜单,集成 GitHub 链接和语言切换器
|
||||
- **Footer 国际化**:动态版权年份、多语言支持链接显示,使用 languageDisplayNames 配置
|
||||
- **FAQSection 灵活配置**:支持工具页面/独立页面切换、标题级别控制、自动 FAQ 数组生成
|
||||
- **内容展示组件**:HowItWorks(步骤动画+视频)、SystemDiagram(架构图)、KeyFeatures(图标+特性说明)
|
||||
|
||||
**UI 组件库架构**:
|
||||
|
||||
- **基于 Radix UI**:Button(CVA 多变体系统)、Accordion(手风琴)、Dialog(模态对话框)、Select、DropdownMenu
|
||||
- **设计系统一致性**:统一的 cn 工具函数、主题色彩系统、动画过渡效果
|
||||
- **组件组合模式**:DialogHeader/DialogFooter/DialogTitle/DialogDescription 组合设计
|
||||
- **懒加载优化**:LazyLoadWrapper 使用 react-intersection-observer,支持 rootMargin 配置防止布局跳动
|
||||
|
||||
**通用组件工具化**:
|
||||
|
||||
- **clipboard_btn**:WriteClipboardButton/ReadClipboardButton 分离设计,集成 useClipboardActions hook,支持国际化消息
|
||||
- **TableOfContents**:支持中文标题 ID 生成、滚动跟踪、层级缩进、IntersectionObserver 监听
|
||||
- **JsonLd SEO**:多类型数据支持、suppressHydrationWarning、数组/单对象处理
|
||||
- **AutoPopupDialog/YouTubePlayer**:业务场景封装,复用性设计
|
||||
|
||||
### 数据流模式
|
||||
|
||||
- **单向数据流**:Store → Hooks → Components
|
||||
- **状态管理集中化**:所有状态通过 `useFileTransferStore` 统一管理
|
||||
- **错误处理标准化**:统一的消息提示机制(putMessageInMs)
|
||||
- **国际化集成**:useLocale + getDictionary 提供多语言支持
|
||||
|
||||
## 7)背压与分片策略深度分析
|
||||
|
||||
### 发送侧双层缓冲架构
|
||||
|
||||
**设计原理**:
|
||||
|
||||
- **文件读取层**:4MB 分片减少 FileReader 调用,8 个分片组成 32MB 批次
|
||||
- **网络传输层**:64KB 小块适配 WebRTC DataChannel 限制,避免 sendData failed 错误
|
||||
- **性能优化**:批次内高效切片,一次 FileReader.read()产生 512 个网络块
|
||||
|
||||
**配置参数**:
|
||||
|
||||
```typescript
|
||||
TransferConfig.FILE_CONFIG = {
|
||||
CHUNK_SIZE: 4194304, // 4MB - 文件读取分片
|
||||
BATCH_SIZE: 8, // 8个分片 = 32MB批次
|
||||
NETWORK_CHUNK_SIZE: 65536, // 64KB - WebRTC安全发送大小
|
||||
};
|
||||
```
|
||||
|
||||
**背压控制机制**:
|
||||
|
||||
- **DataChannel 阈值**:`bufferedAmountLowThreshold = 256KB`(Initiator)和`512KB`(NetworkTransmitter)
|
||||
- **最大缓冲限制**:`maxBuffer = 1MB`,超过时等待背压释放
|
||||
- **异步等待策略**:监听`bufferedamountlow`事件,支持超时机制(10 秒)
|
||||
|
||||
**嵌入元数据包格式**:
|
||||
|
||||
```
|
||||
[4字节长度][JSON元数据][实际数据块]
|
||||
```
|
||||
|
||||
- 每个网络块都包含:chunkIndex、totalChunks、fileOffset、fileId、isLastChunk
|
||||
- 接收端可独立解析,无需依赖额外状态
|
||||
|
||||
### 接收侧智能存储策略
|
||||
|
||||
**存储选择逻辑**:
|
||||
|
||||
```typescript
|
||||
ReceptionConfig.shouldSaveToDisk(fileSize, hasSaveDirectory);
|
||||
```
|
||||
|
||||
- **内存存储**:文件 < 1GB 且未指定保存目录
|
||||
- **磁盘存储**:文件 ≥ 1GB 或用户选择了保存目录
|
||||
- **缓冲管理**:最多缓存 100 个分片(约 6.4MB)
|
||||
|
||||
**分片验证机制**:
|
||||
|
||||
- **格式兼容**:支持 ArrayBuffer/Blob/Uint8Array/TypedArray 多种格式
|
||||
- **完整性检查**:验证 fileId、chunkIndex、chunkSize 一致性
|
||||
- **Firefox 兼容**:Blob size 检测和转换错误处理
|
||||
|
||||
**严格顺序写入**:
|
||||
|
||||
- **SequencedDiskWriter**:确保分片按序写入磁盘,支持大文件流式处理
|
||||
- **断点续传**:通过`getPartialFileSize()`检查本地部分文件
|
||||
- **自动完成检测**:`checkAndAutoFinalize()`验证分片完整性
|
||||
|
||||
### 性能优化细节
|
||||
|
||||
**发送侧优化**:
|
||||
|
||||
- **批量读取**:32MB 批次减少 I/O 操作,提升大文件读取性能
|
||||
- **网络适配**:64KB 块平衡传输效率与浏览器兼容性
|
||||
- **背压响应**:利用 WebRTC 原生背压控制,避免数据丢失
|
||||
|
||||
**接收侧优化**:
|
||||
|
||||
- **格式转换**:ChunkProcessor 统一处理多种数据格式
|
||||
- **进度节流**:文件 100ms、文件夹 200ms 间隔更新,避免 UI 过载
|
||||
- **内存管理**:小文件内存组装,大文件直接写入磁盘
|
||||
|
||||
**错误处理**:
|
||||
|
||||
- **发送重试**:NetworkTransmitter 返回 boolean 状态,支持上层重试逻辑
|
||||
- **转换容错**:Blob conversion failed 时返回 null,不中断整体传输
|
||||
- **超时保护**:文件完成 30 秒超时,优雅关闭 5 秒超时
|
||||
|
||||
### 调试与监控
|
||||
|
||||
**开发环境日志**:
|
||||
|
||||
- **分片跟踪**:每 100 个分片或最后分片记录详细信息
|
||||
- **背压监控**:缓冲区大小变化和等待时间统计
|
||||
- **性能指标**:传输速度、批次处理时间、格式转换耗时
|
||||
|
||||
**生产环境优化**:
|
||||
|
||||
- **条件日志**:`ENABLE_CHUNK_LOGGING`和`ENABLE_PROGRESS_LOGGING`开关
|
||||
- **错误上报**:关键错误通过`postLogToBackend`发送到后端
|
||||
- **性能采样**:通过`performance.now()`精确测量耗时
|
||||
|
||||
## 9)断点续传深度分析
|
||||
|
||||
### 断点续传核心机制
|
||||
|
||||
**续传检测与状态恢复**:
|
||||
|
||||
- **发送侧初始化**:`StreamingFileReader constructor(file, startOffset)` 支持从任意偏移量开始
|
||||
- **接收侧检测**:`StreamingFileWriter.getPartialFileSize()` 通过 File System Access API 检查部分文件
|
||||
- **状态同步**:fileRequest 消息包含 offset 参数,通知发送方从指定位置继续传输
|
||||
|
||||
**分片索引计算**:
|
||||
|
||||
```typescript
|
||||
// 统一的分片计算逻辑
|
||||
const startChunk = Math.floor(startOffset / chunkSize);
|
||||
const expectedChunks = Math.ceil((fileSize - startOffset) / chunkSize);
|
||||
```
|
||||
|
||||
### ChunkRangeCalculator 统一计算器
|
||||
|
||||
**设计目的**:确保发送端和接收端使用完全相同的分片计算逻辑
|
||||
|
||||
```typescript
|
||||
getChunkRange(fileSize, startOffset, chunkSize) {
|
||||
const startChunk = Math.floor(startOffset / chunkSize);
|
||||
const endChunk = Math.floor((fileSize - 1) / chunkSize);
|
||||
return { startChunk, endChunk, totalChunks: endChunk - startChunk + 1 };
|
||||
}
|
||||
```
|
||||
|
||||
**关键方法**:
|
||||
|
||||
- `getRelativeChunkIndex()`:绝对索引转相对索引,用于接收端数组映射
|
||||
- `isChunkIndexValid()`:验证分片索引是否在预期范围内
|
||||
- `calculateExpectedChunks()`:计算预期分片数量,与 ReceptionConfig 保持一致
|
||||
|
||||
### 接收侧续传流程
|
||||
|
||||
**部分文件检测**:
|
||||
|
||||
1. **目录准备**:`createFolderStructure()` 确保目标目录存在
|
||||
2. **文件查询**:通过 `getFileHandle(fileName, {create: false})` 检查文件是否存在
|
||||
3. **大小获取**:`file.getFile()` 获取当前文件大小作为续传起点
|
||||
|
||||
**续传决策逻辑**:
|
||||
|
||||
```typescript
|
||||
// FileReceiveOrchestrator.ts
|
||||
const offset = await this.streamingFileWriter.getPartialFileSize(
|
||||
fileInfo.name,
|
||||
fileInfo.fullName
|
||||
);
|
||||
if (offset === fileInfo.size) {
|
||||
// 文件已完整,跳过传输
|
||||
return;
|
||||
}
|
||||
if (offset > 0) {
|
||||
// 发现部分文件,准备续传
|
||||
// 发送包含 offset 的 fileRequest
|
||||
}
|
||||
```
|
||||
|
||||
### 发送侧续传响应
|
||||
|
||||
**续传准备**:
|
||||
|
||||
- **重置读取器**:`StreamingFileReader.reset(startOffset)` 从新的偏移量开始
|
||||
- **批次调整**:`currentBatchStartOffset` 和 `totalFileOffset` 同步更新
|
||||
- **分片索引**:`startChunkIndex` 记录传输起始点,用于边界检测
|
||||
|
||||
**续传日志**:
|
||||
|
||||
```typescript
|
||||
const chunkRange = ChunkRangeCalculator.getChunkRange(
|
||||
fileSize,
|
||||
startOffset,
|
||||
chunkSize
|
||||
);
|
||||
postLogToBackend(
|
||||
`[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}`
|
||||
);
|
||||
```
|
||||
|
||||
### 续传的优势与限制
|
||||
|
||||
**优势**:
|
||||
|
||||
- **带宽节省**:避免重新传输已接收的数据
|
||||
- **时间效率**:大文件传输中断后可快速恢复
|
||||
- **用户体验**:网络波动不会导致传输进度完全丢失
|
||||
|
||||
**限制与注意点**:
|
||||
|
||||
- **文件一致性**:依赖文件内容未发生变化,续传前应验证文件大小/修改时间
|
||||
- **存储位置**:仅在使用 File System Access API 选择保存目录时支持
|
||||
- **浏览器兼容**:File System Access API 主要支持 Chrome/Edge,其他浏览器降级为内存存储
|
||||
|
||||
**调试支持**:
|
||||
|
||||
- **详细日志**:开发环境下记录续传起点、分片范围、预期传输量
|
||||
- **错误处理**:文件访问失败时回退到从头开始传输
|
||||
- **状态跟踪**:Store 层记录续传状态和实际接收大小
|
||||
|
||||
## 10)重连与状态一致性深度分析
|
||||
|
||||
### WebRTC 基础层重连机制
|
||||
|
||||
**双重断开检测架构**:
|
||||
|
||||
```typescript
|
||||
// webrtc_base.ts
|
||||
private isSocketDisconnected = false; // Socket.IO 连接状态
|
||||
private isPeerDisconnected = false; // P2P 连接状态
|
||||
private gracefullyDisconnectedPeers = new Set(); // 优雅断开的 peer 列表
|
||||
```
|
||||
|
||||
**重连触发条件**:仅当 Socket.IO 和 P2P 连接都断开时才启动重连:
|
||||
|
||||
```typescript
|
||||
// 避免重复重连:socket 断开 ≠ P2P 断开
|
||||
if (
|
||||
this.isSocketDisconnected &&
|
||||
this.isPeerDisconnected &&
|
||||
!this.reconnectionInProgress
|
||||
) {
|
||||
this.attemptReconnection();
|
||||
}
|
||||
```
|
||||
|
||||
### ICE 候选者队列管理
|
||||
|
||||
**候选者缓存策略**:
|
||||
|
||||
- **连接未就绪时**:候选者缓存到 `iceCandidatesQueue` Map,按 peerId 分组
|
||||
- **连接就绪后**:批量处理缓存的候选者,按序添加到 RTCPeerConnection
|
||||
- **失效处理**:候选者失效时重新入队,验证连接状态后重试
|
||||
|
||||
**实现细节**:
|
||||
|
||||
```typescript
|
||||
private iceCandidatesQueue = new Map<string, RTCIceCandidate[]>();
|
||||
// 缓存候选项直到连接就绪
|
||||
if (dataChannel?.readyState !== 'open') {
|
||||
this.queueIceCandidate(candidate, peerId);
|
||||
} else {
|
||||
this.addIceCandidate(candidate, peerId);
|
||||
}
|
||||
```
|
||||
|
||||
### 数据通道发送重试机制
|
||||
|
||||
**5 次重试策略**:
|
||||
|
||||
```typescript
|
||||
async sendToPeer(data: string | ArrayBuffer, peerId: string): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
try {
|
||||
dataChannel.send(data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this.gracefullyDisconnectedPeers.has(peerId)) {
|
||||
return false; // 跳过已优雅断开的 peer
|
||||
}
|
||||
if (attempt === 5) throw error;
|
||||
await new Promise(resolve => setTimeout(resolve, attempt * 100)); // 100ms→1000ms
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**重试间隔递增**:100ms → 200ms → 300ms → 400ms → 500ms,最大 5 次尝试
|
||||
|
||||
### 房间管理层的重连支持
|
||||
|
||||
**幂等性设计**:
|
||||
|
||||
- **长 ID 重连**:≥8 字符的 roomId 支持断线重连时复用房间
|
||||
- **短 ID 限制**:4 位数字 ID 断线后需重新生成房间,避免冲突
|
||||
|
||||
**缓存 ID 重连优化**:
|
||||
|
||||
```typescript
|
||||
// useRoomManager.ts
|
||||
if (roomId.length >= 8) {
|
||||
// 长ID自动发送 initiator-online 信号
|
||||
this.sendInitiatorOnline();
|
||||
}
|
||||
```
|
||||
|
||||
**状态同步序列**:
|
||||
|
||||
1. **发送方重连**:`initiator-online` 信号通知接收方准备重建连接
|
||||
2. **接收方响应**:`recipient-ready` 确认就绪状态
|
||||
3. **WebRTC 协商**:重新开始 offer/answer/ICE 候选者交换
|
||||
4. **传输恢复**:在新的 DataChannel 上恢复文件传输
|
||||
|
||||
### 状态一致性保证机制
|
||||
|
||||
**Store 层单一事实来源**:
|
||||
|
||||
```typescript
|
||||
// fileTransferStore.ts
|
||||
export const useFileTransferStore = create<TransferState>((set, get) => ({
|
||||
sendProgress: new Map(),
|
||||
receiveProgress: new Map(),
|
||||
// 提供清理 API 避免重复计数
|
||||
clearSendProgress: (fileId: string) =>
|
||||
set((state) => {
|
||||
const newProgress = new Map(state.sendProgress);
|
||||
newProgress.delete(fileId);
|
||||
return { sendProgress: newProgress };
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
**连接状态机**:
|
||||
|
||||
```typescript
|
||||
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||
|
||||
// 状态变更时触发相应处理
|
||||
connectionStateChangeHandler(status: ConnectionStatus) {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
this.gracefullyDisconnectedPeers.clear(peerId);
|
||||
this.resetReconnectionState();
|
||||
break;
|
||||
case 'disconnected':
|
||||
case 'failed':
|
||||
this.cleanupExistingConnection(peerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 移动端优化策略
|
||||
|
||||
**唤醒锁管理**:
|
||||
|
||||
```typescript
|
||||
// WakeLockManager
|
||||
async requestWakeLock(): Promise<void> {
|
||||
try {
|
||||
this.wakeLock = await navigator.wakeLock.request('screen');
|
||||
this.wakeLock.addEventListener('release', () => {
|
||||
this.wakeLock = null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Wake lock request failed:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**网络切换适应**:
|
||||
|
||||
- **连接检测**:监听 `connectionstatechange` 事件检测网络质量变化
|
||||
- **自动重连**:`connectionState: 'disconnected' | 'failed' | 'closed'` 时均触发重连流程(统一走 attemptReconnection)
|
||||
- **状态恢复**:重连成功后恢复房间状态和传输进度
|
||||
|
||||
**移动端后台/前台切换补充策略**:
|
||||
|
||||
- **socket 连接恢复自动入房**:`socket.on('connect')` 时,若已持有 `roomId` 且(`lastJoinedSocketId !== socket.id` 或 `!isInRoom`),则强制重新 `joinRoom(roomId, isInitiator, isInitiator)`;发送端会自动广播 `initiator-online`,接收端回复 `recipient-ready`。
|
||||
- **身份追踪**:成功 `joinRoom` 后记录 `lastJoinedSocketId = socket.id`,用以检测“后台恢复时 socketId 更换”的情形。
|
||||
- **门槛放宽**:`attemptReconnection` 只要满足“`roomId` 存在,且满足任一:P2P 断开 / socket 断开 / socketId 改变”,即可发起重连;不再强依赖“socket 与 P2P 同时断开”。
|
||||
|
||||
### 重连调试要点
|
||||
|
||||
**关键日志点**:
|
||||
|
||||
- **双重断开检测**:记录 Socket.IO 和 P2P 断开的具体时间戳
|
||||
- **候选者队列**:统计缓存的 ICE 候选者数量和处理时间
|
||||
- **发送重试**:记录重试次数、间隔和最终结果
|
||||
- **状态恢复**:追踪 `initiator-online` → `recipient-ready` 的时序
|
||||
|
||||
**常见问题诊断**:
|
||||
|
||||
- **重复重连**:检查 `reconnectionInProgress` 标志和 `gracefullyDisconnectedPeers` 集合
|
||||
- **候选者失效**:验证 `iceConnectionState` 和 `iceGatheringState` 状态
|
||||
- **状态不一致**:确认 Store 层的进度清理和连接状态同步
|
||||
|
||||
## 11)微方案模板(用于小改动前的对齐)
|
||||
|
||||
标题:<简述>
|
||||
|
||||
背景/问题
|
||||
|
||||
- 要解决的用户场景或缺陷是什么?
|
||||
|
||||
目标与非目标
|
||||
|
||||
- 本次改动包含与不包含的范围?
|
||||
|
||||
影响文件与消息
|
||||
|
||||
- 代码:列出关键文件(如 `frontend/lib/webrtc_base.ts`、`backend/src/socket/handlers.ts`)。
|
||||
- 协议:列出将修改的 DataChannel 消息/字段。
|
||||
|
||||
状态机/流程变化
|
||||
|
||||
- 增删改的阶段;给出简要时序或步骤。
|
||||
|
||||
测试与回归清单
|
||||
|
||||
- 单测/集成(如适用)、手测场景、性能/边界、重连。
|
||||
|
||||
需要更新的文档
|
||||
|
||||
- `code-map.md`(如出现新的入口)
|
||||
- `flows.md`(流程/消息/约束变化)
|
||||
- 相关架构或部署文档(如涉及)
|
||||
@@ -0,0 +1,47 @@
|
||||
# PrivyDrop AI Playbook — 上下文与索引(中文)
|
||||
|
||||
本手册为 AI 与开发者提供一个高信噪比的入口,帮助快速定位到正确的代码位置。仅包含项目上下文与链接索引,不提供步骤化的任务指南。
|
||||
|
||||
## 项目快照
|
||||
|
||||
- 产品:基于 WebRTC 的 P2P 文件/文本分享,浏览器之间通过 RTCDataChannel 直接传输,端到端加密。
|
||||
- 前端:Next.js 14(App Router)、React 18、TypeScript、Tailwind、shadcn/ui。
|
||||
- 后端:Node.js、Express、Socket.IO、Redis;可选 STUN/TURN 做 NAT 穿透。
|
||||
- 隐私立场:服务器不承载文件数据中转;后端仅负责信令与房间协调。
|
||||
|
||||
## 文档索引
|
||||
|
||||
- AI Playbook
|
||||
|
||||
- 代码地图:`docs/ai-playbook/code-map.zh-CN.md`
|
||||
- 流程(含微方案模板):`docs/ai-playbook/flows.zh-CN.md`
|
||||
- 协作规则:`docs/ai-playbook/collab-rules.zh-CN.md`
|
||||
|
||||
- 系统与架构
|
||||
|
||||
- 系统架构:`docs/ARCHITECTURE.md` / `docs/ARCHITECTURE.zh-CN.md`
|
||||
- 前端架构:`docs/FRONTEND_ARCHITECTURE.md` / `docs/FRONTEND_ARCHITECTURE.zh-CN.md`
|
||||
- 后端架构:`docs/BACKEND_ARCHITECTURE.md` / `docs/BACKEND_ARCHITECTURE.zh-CN.md`
|
||||
|
||||
- 部署
|
||||
- 部署指南:`docs/DEPLOYMENT.md` / `docs/DEPLOYMENT.zh-CN.md`
|
||||
- Docker 部署:`docs/DEPLOYMENT_docker.md` / `docs/DEPLOYMENT_docker.zh-CN.md`
|
||||
|
||||
## 关键模块速览
|
||||
|
||||
- 前端核心
|
||||
- Hooks:`frontend/hooks/useWebRTCConnection.ts`(连接编排)、`useRoomManager.ts`(房间生命周期)、`useFileTransferHandler.ts`(负载编排)。
|
||||
- WebRTC 基础:`frontend/lib/webrtc_base.ts`(Socket.IO 信令、RTCPeerConnection、数据通道)。
|
||||
- 角色:`frontend/lib/webrtc_Initiator.ts`、`frontend/lib/webrtc_Recipient.ts`(发起/接收角色行为)。
|
||||
- 发送:`frontend/lib/transfer/*`、`frontend/lib/fileSender.ts`(元数据、分片、进度)。
|
||||
- 接收:`frontend/lib/receive/*`、`frontend/lib/fileReceiver.ts`(组装、校验、持久化)。
|
||||
- Store:`frontend/stores/fileTransferStore.ts`(进度/状态的单一事实来源)。
|
||||
- 后端核心
|
||||
- Socket.IO:`backend/src/socket/handlers.ts`(join、initiator-online、recipient-ready、offer/answer/ice-candidate)。
|
||||
- Services:`backend/src/services/{room,redis,rateLimit}.ts`。
|
||||
- REST:`backend/src/routes/api.ts`(房间、追踪、调试日志)。
|
||||
|
||||
## 维护
|
||||
|
||||
- 保持精简与事实,避免与系统级文档重复。
|
||||
- 本文用于团队协作与快速理解。
|
||||
+6
-4
@@ -20,15 +20,17 @@ module.exports = {
|
||||
{
|
||||
name: "privydrop-frontend",
|
||||
cwd: "./frontend",
|
||||
script: "npm",
|
||||
args: "run start",
|
||||
script: "node",
|
||||
args: ".next/standalone/server.js",
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: "production"
|
||||
NODE_ENV: "production",
|
||||
PORT: 3002,
|
||||
HOSTNAME: "0.0.0.0"
|
||||
},
|
||||
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||
error_file: "/var/log/privydrop-frontend-error.log",
|
||||
out_file: "/var/log/privydrop-frontend-out.log",
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.env*
|
||||
npm-debug.log*
|
||||
.npm
|
||||
coverage
|
||||
.nyc_output
|
||||
*.log
|
||||
public/sw.js
|
||||
public/workbox-*.js
|
||||
@@ -1,4 +1,4 @@
|
||||
NEXT_PUBLIC_API_URL=http://43.142.81.156:3001
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
# Option,Delete if not needed
|
||||
NEXT_PUBLIC_TURN_HOST=43.142.81.156
|
||||
|
||||
@@ -2,4 +2,6 @@ NEXT_PUBLIC_API_URL=https://www.privydrop.app
|
||||
|
||||
NEXT_PUBLIC_TURN_HOST=turn.privydrop.app
|
||||
NEXT_PUBLIC_TURN_USERNAME=[Username]
|
||||
NEXT_PUBLIC_TURN_PASSWORD=[Password]
|
||||
NEXT_PUBLIC_TURN_PASSWORD=[Password]
|
||||
|
||||
NEXT_IMAGE_UNOPTIMIZED=true
|
||||
@@ -0,0 +1,5 @@
|
||||
node-linker=hoisted
|
||||
public-hoist-pattern[]=@next/env
|
||||
public-hoist-pattern[]=styled-jsx
|
||||
public-hoist-pattern[]=@swc/helpers
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Multi-stage build — build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG NO_PROXY
|
||||
|
||||
ENV http_proxy ${HTTP_PROXY} \
|
||||
https_proxy ${HTTPS_PROXY} \
|
||||
no_proxy ${NO_PROXY}
|
||||
|
||||
WORKDIR /app
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm --no-audit --no-fund
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Declare and use build-time public vars after deps installation to avoid cache invalidation when only API/TURN change
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_TURN_HOST
|
||||
ARG NEXT_PUBLIC_TURN_USERNAME
|
||||
ARG NEXT_PUBLIC_TURN_PASSWORD
|
||||
ARG NEXT_IMAGE_UNOPTIMIZED
|
||||
|
||||
# Inject public env vars during frontend build (for client direct access to backend and TURN)
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
|
||||
ENV NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
|
||||
ENV NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
|
||||
ENV NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED}
|
||||
|
||||
# Set environment variables
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Build the app
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create a non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nextjs -u 1001 -G nodejs
|
||||
|
||||
# Copy build artifacts
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY health-check.js ./
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV PORT 3002
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3002
|
||||
|
||||
# Use a Node.js script for health checks (instead of curl)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD node health-check.js
|
||||
|
||||
# Start the app
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
# Keep public env vars at runtime (optional; helps SSR read them)
|
||||
# Re-declare ARGs in this stage so they can expand into ENV
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_TURN_HOST
|
||||
ARG NEXT_PUBLIC_TURN_USERNAME
|
||||
ARG NEXT_PUBLIC_TURN_PASSWORD
|
||||
ARG NEXT_IMAGE_UNOPTIMIZED
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
|
||||
ENV NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
|
||||
ENV NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
|
||||
ENV NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED}
|
||||
@@ -38,19 +38,25 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
|
||||
<ClipboardApp />
|
||||
</div>
|
||||
</section>
|
||||
{/* How It Works Section */}
|
||||
<section aria-label="How It Works">
|
||||
<LazyLoadWrapper>
|
||||
<HowItWorks messages={messages} />
|
||||
</LazyLoadWrapper>
|
||||
</section>
|
||||
{/* Demo Video Section */}
|
||||
<section className="mb-12" aria-label="Product Demo">
|
||||
<LazyLoadWrapper>
|
||||
<h2 className="text-3xl font-bold mb-6 text-center">
|
||||
{messages.text.home.h2_demo}
|
||||
</h2>
|
||||
<p className="text-center mb-6 text-gray-600">
|
||||
<p className="text-center mb-6 text-muted-foreground">
|
||||
{messages.text.home.h2P_demo}
|
||||
</p>
|
||||
<YouTubePlayer videoId={youtube_videoId} />
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<p className="mb-3 text-gray-700">
|
||||
<p className="mb-3 text-foreground">
|
||||
{messages.text.home.watch_tips}
|
||||
</p>
|
||||
<a
|
||||
@@ -72,12 +78,6 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
|
||||
</div>
|
||||
</LazyLoadWrapper>
|
||||
</section>
|
||||
{/* How It Works Section */}
|
||||
<section aria-label="How It Works">
|
||||
<LazyLoadWrapper>
|
||||
<HowItWorks messages={messages} />
|
||||
</LazyLoadWrapper>
|
||||
</section>
|
||||
{/* System Architecture Section */}
|
||||
<section aria-label="System Architecture">
|
||||
<LazyLoadWrapper>
|
||||
@@ -87,8 +87,8 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
|
||||
{/* Key Features */}
|
||||
<section aria-label="Key Features">
|
||||
<LazyLoadWrapper>
|
||||
<KeyFeatures
|
||||
messages={messages}
|
||||
<KeyFeatures
|
||||
messages={messages}
|
||||
isInToolPage
|
||||
titleClassName="text-2xl md:text-3xl"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Metadata } from "next";
|
||||
import { getPostBySlug } from "@/lib/blog";
|
||||
import { generateMetadata as generateBlogMetadata } from "../metadata";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -16,8 +18,12 @@ export async function generateMetadata({
|
||||
return generateBlogMetadata({ params: { lang: params.lang } });
|
||||
}
|
||||
|
||||
const messages = await getDictionary(params.lang);
|
||||
const blogWord = messages.text.Header.Blog_dis;
|
||||
const blogCap = blogWord.charAt(0).toUpperCase() + blogWord.slice(1);
|
||||
|
||||
return {
|
||||
title: `${post.frontmatter.title} | PrivyDrop Blog`,
|
||||
title: `${post.frontmatter.title} | PrivyDrop ${blogCap}`,
|
||||
description: post.frontmatter.description,
|
||||
keywords: `${post.frontmatter.tags.join(
|
||||
", "
|
||||
@@ -25,10 +31,9 @@ export async function generateMetadata({
|
||||
metadataBase: new URL("https://www.privydrop.app"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}/blog/${params.slug}`,
|
||||
languages: {
|
||||
en: `/en/blog/${params.slug}`,
|
||||
zh: `/zh/blog/${params.slug}`,
|
||||
},
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map((l) => [l, `/${l}/blog/${params.slug}`])
|
||||
),
|
||||
},
|
||||
openGraph: {
|
||||
title: post.frontmatter.title,
|
||||
|
||||
@@ -6,6 +6,14 @@ import { mdxOptions } from "@/lib/mdx-config";
|
||||
import { mdxComponents } from "@/components/blog/MDXComponents";
|
||||
import { TableOfContents } from "@/components/blog/TableOfContents";
|
||||
import { generateMetadata } from "./metadata";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildBlogPostingJsonLd,
|
||||
buildBreadcrumbJsonLd,
|
||||
getSiteUrl,
|
||||
} from "@/lib/seo/jsonld";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
|
||||
export { generateMetadata };
|
||||
|
||||
@@ -15,24 +23,48 @@ export default async function BlogPost({
|
||||
params: { slug: string; lang: string };
|
||||
}) {
|
||||
const post = await getPostBySlug(params.slug, params.lang);
|
||||
const messages = await getDictionary(params.lang);
|
||||
|
||||
if (!post) {
|
||||
return <div>Post not found</div>;
|
||||
return <div>{messages.text.blog.post_not_found}</div>;
|
||||
}
|
||||
|
||||
const siteUrl = getSiteUrl();
|
||||
const postUrl = `${siteUrl}/${params.lang}/blog/${params.slug}`;
|
||||
const imageUrl = absoluteUrl(post.frontmatter.cover, siteUrl);
|
||||
const postLd = buildBlogPostingJsonLd({
|
||||
siteUrl,
|
||||
url: postUrl,
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
authorName: post.frontmatter.author,
|
||||
imageUrl,
|
||||
inLanguage: params.lang,
|
||||
});
|
||||
const breadcrumbsLd = buildBreadcrumbJsonLd({
|
||||
items: [
|
||||
{ name: messages.text.Header.Home_dis, item: `${siteUrl}/${params.lang}` },
|
||||
{ name: messages.text.Header.Blog_dis, item: `${siteUrl}/${params.lang}/blog` },
|
||||
{ name: post.frontmatter.title, item: postUrl },
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
<JsonLd id="post-ld" data={[postLd, breadcrumbsLd]} />
|
||||
{/* Use md: prefix to handle flex layout for medium screens and above */}
|
||||
<div className="block md:flex md:gap-8">
|
||||
{/* Article content area */}
|
||||
<article className="w-full md:flex-1 max-w-4xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-4 text-foreground">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center text-gray-600 gap-2 sm:gap-4">
|
||||
<div className="flex flex-wrap items-center text-muted-foreground gap-2 sm:gap-4">
|
||||
<time className="text-sm">
|
||||
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(params.lang, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -40,7 +72,7 @@ export default async function BlogPost({
|
||||
</time>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span className="text-sm">
|
||||
by <span className="font-bold">{post.frontmatter.author}</span>
|
||||
{messages.text.blog.by} <span className="font-bold">{post.frontmatter.author}</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -51,7 +83,7 @@ export default async function BlogPost({
|
||||
components={{
|
||||
...mdxComponents,
|
||||
wrapper: ({ children }) => (
|
||||
<div className="space-y-4 text-gray-700 overflow-x-auto">
|
||||
<div className="space-y-4 text-foreground overflow-x-auto">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@@ -60,7 +92,7 @@ export default async function BlogPost({
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
<TableOfContents content={post.content} />
|
||||
<TableOfContents content={post.content} title={messages.text.blog.toc_title} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
import { Metadata } from "next";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { lang: string };
|
||||
}): Promise<Metadata> {
|
||||
const messages = await getDictionary(params.lang);
|
||||
|
||||
return {
|
||||
title: "PrivyDrop Blog - Private P2P File Sharing & Collaboration",
|
||||
description:
|
||||
"Discover secure file sharing tips, privacy-focused collaboration strategies, and how to leverage P2P technology for safer data transfer. Learn about WebRTC, end-to-end encryption, and team collaboration.",
|
||||
keywords:
|
||||
"secure file sharing, p2p file transfer, private collaboration, webrtc, end-to-end encryption, team collaboration, privacy tools",
|
||||
title: messages.meta.blog.title,
|
||||
description: messages.meta.blog.description,
|
||||
keywords: messages.meta.blog.keywords,
|
||||
metadataBase: new URL("https://www.privydrop.app"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}/blog`,
|
||||
languages: {
|
||||
en: "/en/blog",
|
||||
zh: "/zh/blog",
|
||||
},
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map((l) => [l, `/${l}/blog`])
|
||||
),
|
||||
},
|
||||
openGraph: {
|
||||
title: "PrivyDrop Blog - Private P2P File Sharing & Collaboration",
|
||||
description:
|
||||
"Explore secure file sharing, private collaboration tools, and data privacy best practices. Join our community of privacy-conscious professionals.",
|
||||
title: messages.meta.blog.title,
|
||||
description: messages.meta.blog.description,
|
||||
url: `https://www.privydrop.app/${params.lang}/blog`,
|
||||
siteName: "PrivyDrop",
|
||||
locale: params.lang,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArticleListItem } from "@/components/blog/ArticleListItem";
|
||||
import Link from "next/link";
|
||||
import { slugifyTag } from "@/utils/tagUtils";
|
||||
import { generateMetadata } from "./metadata";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
|
||||
export { generateMetadata };
|
||||
|
||||
@@ -12,6 +13,7 @@ export default async function BlogPage({
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const posts = await getAllPosts(lang);
|
||||
const messages = await getDictionary(lang);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
@@ -19,14 +21,14 @@ export default async function BlogPage({
|
||||
{/* Main Content */}
|
||||
<main className="lg:col-span-8">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold mb-4">Blog</h1>
|
||||
<p className="text-gray-600 text-lg">Latest articles and updates</p>
|
||||
<h1 className="text-4xl font-bold mb-4">{messages.text.blog.list_title}</h1>
|
||||
<p className="text-muted-foreground text-lg">{messages.text.blog.list_subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Articles List */}
|
||||
<div className="space-y-12">
|
||||
{posts.map((post) => (
|
||||
<ArticleListItem key={post.slug} post={post} lang={lang} />
|
||||
<ArticleListItem key={post.slug} post={post} lang={lang} messages={messages} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
@@ -35,14 +37,14 @@ export default async function BlogPage({
|
||||
<aside className="lg:col-span-4">
|
||||
<div className="sticky top-8">
|
||||
{/* Recent Posts */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 mb-8">
|
||||
<h2 className="text-xl font-bold mb-6">Recent Posts</h2>
|
||||
<div className="bg-card rounded-xl shadow-lg p-8 mb-8">
|
||||
<h2 className="text-xl font-bold mb-6">{messages.text.blog.recent_posts}</h2>
|
||||
<div className="space-y-4">
|
||||
{posts.slice(0, 5).map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/${lang}/blog/${post.slug}`}
|
||||
className="block hover:text-blue-600 text-base font-medium"
|
||||
className="block hover:text-primary text-base font-medium"
|
||||
>
|
||||
{post.frontmatter.title}
|
||||
</Link>
|
||||
@@ -50,8 +52,8 @@ export default async function BlogPage({
|
||||
</div>
|
||||
</div>
|
||||
{/* tags */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<h2 className="text-xl font-bold mb-6">Tags</h2>
|
||||
<div className="bg-card rounded-xl shadow-lg p-8">
|
||||
<h2 className="text-xl font-bold mb-6">{messages.text.blog.tags}</h2>
|
||||
<div className="space-y-3">
|
||||
{/* Get all tags and deduplicate */}
|
||||
{Array.from(
|
||||
@@ -60,10 +62,10 @@ export default async function BlogPage({
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/${lang}/blog/tag/${slugifyTag(tag)}`} // Jump to the tag filtering page
|
||||
className="flex items-center justify-between hover:text-blue-600"
|
||||
className="flex items-center justify-between hover:text-primary"
|
||||
>
|
||||
<span className="text-gray-700 font-medium">{tag}</span>
|
||||
<span className="bg-gray-100 px-3 py-1 rounded-full text-sm text-gray-600">
|
||||
<span className="text-foreground font-medium">{tag}</span>
|
||||
<span className="bg-muted px-3 py-1 rounded-full text-sm text-muted-foreground">
|
||||
{
|
||||
posts.filter((p) => p.frontmatter.tags.includes(tag))
|
||||
.length
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getPostsByTag } from "@/lib/blog";
|
||||
import { ArticleListItem } from "@/components/blog/ArticleListItem";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
import { unslugifyTag } from "@/utils/tagUtils";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { tag, lang },
|
||||
@@ -10,25 +11,24 @@ export async function generateMetadata({
|
||||
params: { tag: string; lang: string };
|
||||
}): Promise<Metadata> {
|
||||
const decodedTag = unslugifyTag(tag);
|
||||
const messages = await getDictionary(lang);
|
||||
|
||||
// Note: metadata text kept concise and localized
|
||||
return {
|
||||
title: `${decodedTag} - PrivyDrop Blog Articles`,
|
||||
description: `Explore articles about ${decodedTag} - Learn about secure file sharing, private collaboration, and data privacy solutions related to ${decodedTag}`,
|
||||
keywords: `${decodedTag}, secure file sharing, p2p file transfer, privacy, collaboration, webrtc`,
|
||||
title: `${messages.text.blog.tag_title_prefix}: ${decodedTag} - PrivyDrop`,
|
||||
description: messages.text.blog.tag_subtitle_template.replace("{tag}", decodedTag),
|
||||
keywords: `${decodedTag}, blog, privydrop`,
|
||||
metadataBase: new URL("https://www.privydrop.app"),
|
||||
alternates: {
|
||||
canonical: `/${lang}/blog/tag/${encodeURIComponent(tag)}`,
|
||||
languages: {
|
||||
en: `/en/blog/tag/${encodeURIComponent(tag)}`,
|
||||
zh: `/zh/blog/tag/${encodeURIComponent(tag)}`,
|
||||
},
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map((l) => [l, `/${l}/blog/tag/${encodeURIComponent(tag)}`])
|
||||
),
|
||||
},
|
||||
openGraph: {
|
||||
title: `${decodedTag} - PrivyDrop Blog Articles`,
|
||||
description: `Discover articles about ${decodedTag} - Expert insights on secure file sharing and private collaboration solutions`,
|
||||
url: `https://www.privydrop.app/${lang}/blog/tag/${encodeURIComponent(
|
||||
tag
|
||||
)}`,
|
||||
title: `${decodedTag} - PrivyDrop`,
|
||||
description: `Articles tagged: ${decodedTag}`,
|
||||
url: `https://www.privydrop.app/${lang}/blog/tag/${encodeURIComponent(tag)}`,
|
||||
siteName: "PrivyDrop",
|
||||
locale: lang,
|
||||
type: "website",
|
||||
@@ -42,6 +42,7 @@ export default async function TagPage({
|
||||
}) {
|
||||
const decodedTag = unslugifyTag(tag);
|
||||
const posts = await getPostsByTag(decodedTag, lang);
|
||||
const messages = await getDictionary(lang);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
@@ -49,9 +50,9 @@ export default async function TagPage({
|
||||
{/* Main Content */}
|
||||
<main className="lg:col-span-8">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold mb-4">Tag: {decodedTag}</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Articles tagged with {decodedTag}
|
||||
<h1 className="text-4xl font-bold mb-4">{messages.text.blog.tag_title_prefix}: {decodedTag}</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{messages.text.blog.tag_subtitle_template.replace("{tag}", decodedTag)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -59,10 +60,10 @@ export default async function TagPage({
|
||||
<div className="space-y-12">
|
||||
{posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<ArticleListItem key={post.slug} post={post} lang={lang} />
|
||||
<ArticleListItem key={post.slug} post={post} lang={lang} messages={messages} />
|
||||
))
|
||||
) : (
|
||||
<p>No articles found for this decodedTag.</p>
|
||||
<p>{messages.text.blog.tag_empty}</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,8 @@ import FAQSection from "@/components/web/FAQSection";
|
||||
import type { Metadata } from "next";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import { buildFaqJsonLd } from "@/lib/seo/jsonld";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -38,5 +40,25 @@ export default async function FAQ({
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
return <FAQSection messages={messages} />;
|
||||
const faqsData = (messages as any).text.faqs as Record<string, string>;
|
||||
const questionKeys = Object.keys(faqsData).filter((k) => k.startsWith("question_"));
|
||||
const faqs = questionKeys
|
||||
.map((qKey) => {
|
||||
const idx = qKey.split("_")[1];
|
||||
const aKey = `answer_${idx}`;
|
||||
const q = faqsData[qKey];
|
||||
const a = faqsData[aKey];
|
||||
if (q && a) return { question: q, answer: a };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as { question: string; answer: string }[];
|
||||
|
||||
const faqLd = buildFaqJsonLd({ inLanguage: lang, faqs });
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd id="faq-ld" data={faqLd} />
|
||||
<FAQSection messages={messages} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,15 +69,16 @@
|
||||
}
|
||||
|
||||
/* Custom prose styles */
|
||||
|
||||
.prose {
|
||||
@apply text-gray-600;
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
@apply text-gray-900 font-bold mt-8 mb-4;
|
||||
@apply text-foreground font-bold mt-8 mb-4;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
@@ -106,31 +107,31 @@
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@apply text-sm bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200;
|
||||
@apply text-sm bg-muted rounded px-1.5 py-0.5 text-foreground border border-border;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
@apply my-6 p-4 bg-gray-50 rounded-lg overflow-x-auto border border-gray-200;
|
||||
@apply my-6 p-4 bg-muted rounded-lg overflow-x-auto border border-border;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
@apply bg-transparent text-gray-800 p-0 border-0;
|
||||
@apply bg-transparent text-foreground p-0 border-0;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
@apply border-l-4 border-blue-500 pl-4 my-6 italic text-gray-600;
|
||||
@apply border-l-4 border-primary pl-4 my-6 italic text-muted-foreground;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
@apply min-w-full divide-y divide-gray-200 my-6;
|
||||
@apply min-w-full divide-y divide-border my-6;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
||||
@apply px-6 py-3 bg-muted text-left text-xs font-medium text-muted-foreground uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.prose td {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-500;
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
@@ -138,7 +139,7 @@
|
||||
}
|
||||
|
||||
.prose figure figcaption {
|
||||
@apply text-center text-sm text-gray-600 mt-2 italic;
|
||||
@apply text-center text-sm text-muted-foreground mt-2 italic;
|
||||
}
|
||||
|
||||
/* Hide GitHub ribbon on small screens */
|
||||
|
||||
@@ -3,6 +3,13 @@ import Header from "@/components/web/Header";
|
||||
import Footer from "@/components/web/Footer";
|
||||
import { ThemeProvider } from "@/components/web/theme-provider";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildOrganizationJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
getSiteUrl,
|
||||
} from "@/lib/seo/jsonld";
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
@@ -12,11 +19,27 @@ export default async function RootLayout({
|
||||
params: { lang: string };
|
||||
}>) {
|
||||
const messages = await getDictionary(lang);
|
||||
const siteUrl = getSiteUrl();
|
||||
const logoUrl = absoluteUrl("/logo.png", siteUrl);
|
||||
const orgJson = buildOrganizationJsonLd({
|
||||
siteUrl,
|
||||
logoUrl,
|
||||
sameAs: [
|
||||
"https://github.com/david-bai00/PrivyDrop",
|
||||
"https://x.com/David_vision66",
|
||||
],
|
||||
});
|
||||
const websiteJson = buildWebSiteJsonLd({
|
||||
siteUrl,
|
||||
name: "PrivyDrop",
|
||||
inLanguage: lang,
|
||||
});
|
||||
|
||||
return (
|
||||
<html lang={lang} className="h-full" suppressHydrationWarning>
|
||||
<head />
|
||||
<body className="min-h-full flex flex-col">
|
||||
<JsonLd id="global-ld" data={[orgJson, websiteJson]} />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
||||
@@ -2,6 +2,8 @@ import HomeClient from "./HomeClient";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { Metadata } from "next";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import { buildWebAppJsonLd, getSiteUrl, absoluteUrl } from "@/lib/seo/jsonld";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -39,6 +41,27 @@ export default async function Home({
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
const siteUrl = getSiteUrl();
|
||||
const webAppLd = buildWebAppJsonLd({
|
||||
siteUrl,
|
||||
path: `/${lang}`,
|
||||
name: "PrivyDrop",
|
||||
alternateName: [
|
||||
"PrivyDrop",
|
||||
"PrivyDrop APP",
|
||||
"Open-source web-based AirDrop alternative",
|
||||
],
|
||||
description: messages.meta.home.description,
|
||||
inLanguage: lang,
|
||||
imageUrl: absoluteUrl("/logo.png", siteUrl),
|
||||
applicationCategory: "UtilityApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
});
|
||||
|
||||
return <HomeClient messages={messages} lang={lang} />;
|
||||
return (
|
||||
<>
|
||||
<JsonLd id="home-ld" data={webAppLd} />
|
||||
<HomeClient messages={messages} lang={lang} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const errors: string[] = [];
|
||||
let status = 'healthy';
|
||||
|
||||
// 基础健康信息
|
||||
const basicHealth = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-frontend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
};
|
||||
|
||||
// Check backend API connectivity
|
||||
const backendHealth = await checkBackendHealth();
|
||||
if (backendHealth.status !== 'connected') {
|
||||
errors.push('Backend API connection failed');
|
||||
status = 'degraded';
|
||||
}
|
||||
|
||||
// System info snapshot
|
||||
const systemInfo = {
|
||||
runtime: process.env.NEXT_RUNTIME || 'nodejs',
|
||||
nextjs: {
|
||||
version: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
},
|
||||
memory: process.memoryUsage ? {
|
||||
used: formatBytes(process.memoryUsage().heapUsed),
|
||||
total: formatBytes(process.memoryUsage().heapTotal),
|
||||
percent: Math.round((process.memoryUsage().heapUsed / process.memoryUsage().heapTotal) * 100)
|
||||
} : null
|
||||
};
|
||||
|
||||
const detailedHealth = {
|
||||
...basicHealth,
|
||||
status,
|
||||
dependencies: {
|
||||
backend: backendHealth
|
||||
},
|
||||
system: systemInfo,
|
||||
...(errors.length > 0 && { errors })
|
||||
};
|
||||
|
||||
const httpStatus = status === 'healthy' ? 200 : 503;
|
||||
return NextResponse.json(detailedHealth, { status: httpStatus });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Detailed frontend health check error:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-frontend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check backend API health
|
||||
async function checkBackendHealth() {
|
||||
try {
|
||||
// Prefer container-internal URL, then public URL, then localhost fallback
|
||||
const backendUrl = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
const start = Date.now();
|
||||
|
||||
const response = await fetch(`${backendUrl}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Timeout to avoid long-hanging connections in degraded networks
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
status: 'connected',
|
||||
responseTime,
|
||||
backendUrl,
|
||||
backendService: data.service || 'unknown'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'error',
|
||||
responseTime,
|
||||
backendUrl,
|
||||
httpStatus: response.status,
|
||||
error: `HTTP ${response.status}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'disconnected',
|
||||
backendUrl: process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化字节数
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-frontend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
nextjs: {
|
||||
version: process.env.NEXT_RUNTIME || 'nodejs'
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json(health, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Frontend health check error:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-frontend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ManagerOptions, SocketOptions } from "socket.io-client";
|
||||
|
||||
export const config = {
|
||||
API_URL: process.env.NEXT_PUBLIC_API_URL!,
|
||||
USE_HTTPS: process.env.NODE_ENV !== "development",
|
||||
@@ -11,43 +13,55 @@ export const config = {
|
||||
};
|
||||
|
||||
export const getIceServers = () => {
|
||||
// Default public STUN server
|
||||
const iceServers: RTCIceServer[] = [
|
||||
{
|
||||
const iceServers: RTCIceServer[] = [];
|
||||
|
||||
if (config.USE_HTTPS) {
|
||||
// Check if TURN server configuration is complete
|
||||
if (!config.TURN_HOST || !config.TURN_USERNAME || !config.TURN_CREDENTIAL) {
|
||||
console.warn(
|
||||
"TURN server configuration incomplete in HTTPS environment. " +
|
||||
"Please set NEXT_PUBLIC_TURN_HOST, NEXT_PUBLIC_TURN_USERNAME, and NEXT_PUBLIC_TURN_PASSWORD " +
|
||||
"environment variables for better connectivity. Falling back to Google STUN server."
|
||||
);
|
||||
|
||||
// Fallback to Google STUN server
|
||||
iceServers.push({
|
||||
urls: "stun:stun.l.google.com:19302",
|
||||
});
|
||||
} else {
|
||||
// Add self-hosted STUN and TURN servers
|
||||
iceServers.push(
|
||||
{
|
||||
urls: `stun:${config.TURN_HOST}:3478`,
|
||||
},
|
||||
{
|
||||
urls: `turns:${config.TURN_HOST}:443`,
|
||||
username: config.TURN_USERNAME,
|
||||
credential: config.TURN_CREDENTIAL,
|
||||
},
|
||||
{
|
||||
urls: `turn:${config.TURN_HOST}:3478`,
|
||||
username: config.TURN_USERNAME,
|
||||
credential: config.TURN_CREDENTIAL,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Development environment uses Google's public STUN server
|
||||
iceServers.push({
|
||||
urls: "stun:stun.l.google.com:19302",
|
||||
},
|
||||
];
|
||||
|
||||
// Add self-hosted TURN/STUN server if configured through environment variables
|
||||
if (config.TURN_HOST && config.TURN_USERNAME && config.TURN_CREDENTIAL) {
|
||||
const turnUrls = config.USE_HTTPS
|
||||
? [`turns:${config.TURN_HOST}:443`, `turn:${config.TURN_HOST}:3478`]
|
||||
: [`turn:${config.TURN_HOST}:3478`];
|
||||
|
||||
// Add STUN from the self-hosted server
|
||||
iceServers.push({
|
||||
urls: `stun:${config.TURN_HOST}:3478`,
|
||||
});
|
||||
|
||||
// Add TURN from the self-hosted server
|
||||
iceServers.push({
|
||||
urls: turnUrls,
|
||||
username: config.TURN_USERNAME,
|
||||
credential: config.TURN_CREDENTIAL,
|
||||
});
|
||||
}
|
||||
|
||||
return iceServers;
|
||||
};
|
||||
|
||||
export const getSocketOptions = () => {
|
||||
return config.USE_HTTPS
|
||||
? {
|
||||
secure: true,
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
}
|
||||
: undefined;
|
||||
export const getSocketOptions = (): Partial<ManagerOptions & SocketOptions> => {
|
||||
// Allow polling fallback; do not force "secure" here — protocol will be inferred
|
||||
return {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket", "polling"],
|
||||
};
|
||||
};
|
||||
|
||||
export const getFetchOptions = (options: RequestInit = {}): RequestInit => {
|
||||
|
||||
+56
-15
@@ -1,6 +1,7 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
import { getAllPosts } from "@/lib/blog";
|
||||
import { slugifyTag } from "@/utils/tagUtils";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = "https://www.privydrop.app";
|
||||
@@ -26,23 +27,33 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
// Add language specific URLs
|
||||
languages.forEach((lang) => {
|
||||
routes.forEach((route) => {
|
||||
urls.push({
|
||||
url: `${baseUrl}/${lang}${route}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: route === "" ? 1.0 : 0.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add blog posts for each language
|
||||
// Add language specific URLs, blog posts and tag pages
|
||||
for (const lang of languages) {
|
||||
try {
|
||||
const posts = await getAllPosts(lang);
|
||||
|
||||
|
||||
// compute latest blog post date for this language
|
||||
const latestDate = posts.length
|
||||
? new Date(
|
||||
Math.max(
|
||||
...posts.map((p) => new Date(p.frontmatter.date).getTime())
|
||||
)
|
||||
)
|
||||
: new Date();
|
||||
|
||||
// Add static routes per language (optimize blog list lastModified)
|
||||
routes.forEach((route) => {
|
||||
const isRoot = route === "";
|
||||
const isBlogList = route === "/blog";
|
||||
urls.push({
|
||||
url: `${baseUrl}/${lang}${route}`,
|
||||
lastModified: isBlogList ? latestDate : new Date(),
|
||||
changeFrequency: isRoot ? "weekly" : isBlogList ? "weekly" : "weekly",
|
||||
priority: isRoot ? 1.0 : 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
// Add blog posts for this language
|
||||
posts.forEach((post) => {
|
||||
urls.push({
|
||||
url: `${baseUrl}/${lang}/blog/${post.slug}`,
|
||||
@@ -51,8 +62,38 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
priority: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
// Add tag pages for this language
|
||||
const uniqueTags = Array.from(
|
||||
new Set(posts.flatMap((p) => p.frontmatter.tags))
|
||||
);
|
||||
uniqueTags.forEach((tag) => {
|
||||
const tagSlug = slugifyTag(tag);
|
||||
const tagLatestDate = posts
|
||||
.filter((p) => p.frontmatter.tags.includes(tag))
|
||||
.map((p) => new Date(p.frontmatter.date).getTime());
|
||||
const lastModified =
|
||||
tagLatestDate.length > 0
|
||||
? new Date(Math.max(...tagLatestDate))
|
||||
: latestDate;
|
||||
urls.push({
|
||||
url: `${baseUrl}/${lang}/blog/tag/${tagSlug}`,
|
||||
lastModified,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load blog posts for language ${lang}:`, error);
|
||||
console.warn(`Failed to load blog data for language ${lang}:`, error);
|
||||
// Fallback: keep at least the static routes
|
||||
routes.forEach((route) => {
|
||||
urls.push({
|
||||
url: `${baseUrl}/${lang}${route}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: route === "" ? 1.0 : 0.8,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { RetrieveTabPanel } from "./ClipboardApp/RetrieveTabPanel";
|
||||
import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone";
|
||||
import { traverseFileTree } from "@/lib/fileUtils";
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
import { getCachedId } from "@/lib/roomIdCache";
|
||||
|
||||
const ClipboardApp = () => {
|
||||
const { shareMessage, retrieveMessage, putMessageInMs } =
|
||||
@@ -37,6 +38,9 @@ const ClipboardApp = () => {
|
||||
setIsDragging,
|
||||
setRetrieveRoomIdInput,
|
||||
setActiveTab,
|
||||
// for auto-join on receiver side
|
||||
isReceiverInRoom,
|
||||
retrieveRoomIdInput,
|
||||
} = useFileTransferStore();
|
||||
|
||||
const richTextToPlainText = useRichTextToPlainText();
|
||||
@@ -49,43 +53,18 @@ const ClipboardApp = () => {
|
||||
handleDownloadFile,
|
||||
} = useFileTransferHandler({ messages, putMessageInMs });
|
||||
|
||||
// 简化的 WebRTC 连接初始化
|
||||
// Simplified WebRTC connection initialization
|
||||
const {
|
||||
sharePeerCount,
|
||||
retrievePeerCount,
|
||||
broadcastDataToAllPeers,
|
||||
requestFile,
|
||||
requestFolder,
|
||||
setReceiverDirectoryHandle,
|
||||
getReceiverSaveType,
|
||||
senderDisconnected,
|
||||
resetReceiverConnection,
|
||||
resetSenderConnection,
|
||||
manualSafeSave,
|
||||
} = useWebRTCConnection({
|
||||
messages,
|
||||
putMessageInMs,
|
||||
});
|
||||
|
||||
const resetAppState = useCallback(async () => {
|
||||
try {
|
||||
// Reset file transfer state
|
||||
useFileTransferStore.getState().resetReceiverState();
|
||||
|
||||
// Reset WebRTC connection state
|
||||
await resetReceiverConnection();
|
||||
|
||||
// Reset room input
|
||||
setRetrieveRoomIdInput("");
|
||||
|
||||
console.log("Application state reset successfully");
|
||||
} catch (error) {
|
||||
console.error("Error during state reset:", error);
|
||||
window.location.reload();
|
||||
}
|
||||
}, [resetReceiverConnection, setRetrieveRoomIdInput]);
|
||||
|
||||
// 大大简化的房间管理 - 不再需要传递任何 WebRTC 依赖
|
||||
// Greatly simplified room management - No longer need to pass any WebRTC dependencies
|
||||
const {
|
||||
processRoomIdInput,
|
||||
joinRoom,
|
||||
@@ -169,6 +148,32 @@ const ClipboardApp = () => {
|
||||
};
|
||||
}, [activeTab, handleFileDrop, setIsDragging]);
|
||||
|
||||
// Auto-join on switching to receiver tab when cached ID exists
|
||||
useEffect(() => {
|
||||
if (activeTab !== "retrieve") return;
|
||||
if (isReceiverInRoom) return;
|
||||
|
||||
// Do not auto-join if URL already specifies a roomId (URL 优先)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("roomId")) return;
|
||||
|
||||
// Do not override user's existing input
|
||||
if ((retrieveRoomIdInput || "").trim().length > 0) return;
|
||||
|
||||
const cached = getCachedId();
|
||||
if (!cached || cached.trim().length === 0) return;
|
||||
|
||||
// Fill input then join directly to improve UX
|
||||
setRetrieveRoomIdInput(cached);
|
||||
joinRoom(false, cached);
|
||||
}, [
|
||||
activeTab,
|
||||
isReceiverInRoom,
|
||||
retrieveRoomIdInput,
|
||||
setRetrieveRoomIdInput,
|
||||
joinRoom,
|
||||
]);
|
||||
|
||||
if (isLoadingMessages || !messages) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
|
||||
@@ -227,6 +232,7 @@ const ClipboardApp = () => {
|
||||
shareMessage={shareMessage}
|
||||
currentValidatedShareRoomId={shareRoomId}
|
||||
handleLeaveSenderRoom={handleLeaveSenderRoom}
|
||||
putMessageInMs={putMessageInMs}
|
||||
/>
|
||||
) : (
|
||||
<RetrieveTabPanel
|
||||
@@ -243,7 +249,6 @@ const ClipboardApp = () => {
|
||||
getReceiverSaveType={getReceiverSaveType}
|
||||
retrieveMessage={retrieveMessage}
|
||||
handleLeaveRoom={handleLeaveReceiverRoom}
|
||||
manualSafeSave={manualSafeSave}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import { getCachedId, setCachedId } from "@/lib/roomIdCache";
|
||||
|
||||
/**
|
||||
* CachedIdActionButton
|
||||
*
|
||||
* A reusable action button that unifies the "Use cached ID" and "Save ID" behaviors
|
||||
* across sender and receiver panels.
|
||||
*
|
||||
* UX
|
||||
* - If a cached Room ID exists:
|
||||
* - Single click (no second click within dblClickWindowMs, default 400ms):
|
||||
* writes the cached ID into the target input without availability checks
|
||||
* (matching the current Random ID UX).
|
||||
* - Double click (two clicks within dblClickWindowMs): switches to a temporary
|
||||
* "Save ID" mode for saveModeDurationMs (default 3000ms) without filling.
|
||||
* - If no cached Room ID exists: the button shows "Save ID" by default; when the
|
||||
* current input length >= 8, clicking saves it to localStorage and reports success
|
||||
* via putMessageInMs, then the button returns to "Use cached ID".
|
||||
* - In "Save ID" mode: clicking saves the current input (>= 8) and exits the mode;
|
||||
* if the user does nothing, the mode auto-exits after saveModeDurationMs.
|
||||
*
|
||||
* Props
|
||||
* - messages: i18n dictionary used for labels/tooltips.
|
||||
* - getInputValue / setInputValue: provide read/write access to the room ID input.
|
||||
* - putMessageInMs: message dispatcher; isShareEnd tells which side (sender/receiver)
|
||||
* should display the toast.
|
||||
* - Optional styling/timing overrides: className, variant, size, dblClickWindowMs,
|
||||
* saveModeDurationMs — with sensible defaults for drop‑in usage.
|
||||
*
|
||||
* Implementation
|
||||
* - Local state tracks if a cached ID exists and whether we are in temporary
|
||||
* "save override" mode.
|
||||
* - Single/double click detection uses a short timer + click counter refs;
|
||||
* timers are cleaned up on unmount to avoid leaks.
|
||||
* - localStorage reads/writes are abstracted via getCachedId/setCachedId.
|
||||
* - No network calls, and no availability checks during "Use cached ID" to keep
|
||||
* the interaction snappy and consistent with Random ID behavior.
|
||||
*/
|
||||
|
||||
type Props = {
|
||||
messages: Messages;
|
||||
getInputValue: () => string;
|
||||
setInputValue: (val: string) => void;
|
||||
putMessageInMs: (
|
||||
message: string,
|
||||
isShareEnd?: boolean,
|
||||
displayTimeMs?: number
|
||||
) => void;
|
||||
isShareEnd: boolean; // true for sender, false for receiver
|
||||
className?: string;
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
dblClickWindowMs?: number; // default 400ms
|
||||
saveModeDurationMs?: number; // default 3000ms
|
||||
// Optional: called after a cached ID is applied (single-click "Use cached ID")
|
||||
onUseCached?: (cachedId: string) => void;
|
||||
// Optional: external disabled flag
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function CachedIdActionButton({
|
||||
messages,
|
||||
getInputValue,
|
||||
setInputValue,
|
||||
putMessageInMs,
|
||||
isShareEnd,
|
||||
className = "w-full sm:w-auto px-4",
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
dblClickWindowMs = 400,
|
||||
saveModeDurationMs = 3000,
|
||||
onUseCached,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const [hasCachedId, setHasCachedId] = useState<boolean>(false);
|
||||
const [showSaveOverride, setShowSaveOverride] = useState<boolean>(false);
|
||||
const clickCountRef = useRef(0);
|
||||
const singleTimerRef = useRef<number | null>(null);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasCachedId(!!getCachedId());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (singleTimerRef.current) {
|
||||
clearTimeout(singleTimerRef.current);
|
||||
singleTimerRef.current = null;
|
||||
}
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isSaveMode = showSaveOverride || !hasCachedId;
|
||||
const inputVal = getInputValue() || "";
|
||||
const isSaveEnabled = inputVal.trim().length >= 8;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isSaveMode) {
|
||||
const trimmed = (getInputValue() || "").trim();
|
||||
if (trimmed.length >= 8) {
|
||||
setCachedId(trimmed);
|
||||
setHasCachedId(true);
|
||||
setShowSaveOverride(false);
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
putMessageInMs(messages.text.ClipboardApp.saveId_success, isShareEnd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use cached with single/double click detection
|
||||
clickCountRef.current += 1;
|
||||
if (clickCountRef.current === 1) {
|
||||
// Single click timer
|
||||
singleTimerRef.current = window.setTimeout(() => {
|
||||
if (clickCountRef.current === 1) {
|
||||
const cached = getCachedId();
|
||||
if (cached) {
|
||||
setInputValue(cached);
|
||||
// Notify caller after applying cached value
|
||||
onUseCached?.(cached);
|
||||
}
|
||||
}
|
||||
clickCountRef.current = 0;
|
||||
if (singleTimerRef.current) {
|
||||
clearTimeout(singleTimerRef.current);
|
||||
singleTimerRef.current = null;
|
||||
}
|
||||
}, dblClickWindowMs);
|
||||
} else if (clickCountRef.current === 2) {
|
||||
// Double click => switch to save mode
|
||||
if (singleTimerRef.current) {
|
||||
clearTimeout(singleTimerRef.current);
|
||||
singleTimerRef.current = null;
|
||||
}
|
||||
clickCountRef.current = 0;
|
||||
setShowSaveOverride(true);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
setShowSaveOverride(false);
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}, saveModeDurationMs);
|
||||
}
|
||||
}, [
|
||||
isSaveMode,
|
||||
getInputValue,
|
||||
setInputValue,
|
||||
putMessageInMs,
|
||||
messages.text.ClipboardApp.saveId_success,
|
||||
isShareEnd,
|
||||
dblClickWindowMs,
|
||||
saveModeDurationMs,
|
||||
onUseCached,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
isSaveMode
|
||||
? messages.text.ClipboardApp.html.saveId_tips
|
||||
: messages.text.ClipboardApp.html.useCachedId_tips
|
||||
}
|
||||
>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
className={className}
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={handleClick}
|
||||
disabled={
|
||||
disabled || (isSaveMode ? !isSaveEnabled : !hasCachedId)
|
||||
}
|
||||
>
|
||||
{isSaveMode
|
||||
? messages.text.ClipboardApp.html.saveId_dis
|
||||
: messages.text.ClipboardApp.html.useCachedId_dis}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { getDictionary } from "@/lib/dictionary";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
import { supportsAutoDownload } from "@/lib/browserUtils";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
|
||||
function formatFolderDis(template: string, num: number, size: string) {
|
||||
return template.replace("{num}", num.toString()).replace("{size}", size);
|
||||
@@ -41,7 +44,6 @@ interface FileListDisplayProps {
|
||||
onRequest?: (item: FileMeta) => void; // Request file
|
||||
onDelete?: (item: FileMeta) => void;
|
||||
onLocationPick?: () => Promise<boolean>;
|
||||
onSafeSave?: () => void; // New prop for safe save functionality
|
||||
saveType?: { [fileId: string]: boolean }; // File stored on disk or in memory
|
||||
largeFileThreshold?: number;
|
||||
}
|
||||
@@ -63,14 +65,13 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
onRequest,
|
||||
onDelete,
|
||||
onLocationPick,
|
||||
onSafeSave,
|
||||
saveType,
|
||||
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const [messages, setMessages] = useState<Messages | null>(null);
|
||||
|
||||
// 获取store的清理方法
|
||||
// Get the cleaning method of the store
|
||||
const { clearSendProgress, clearReceiveProgress } = useFileTransferStore();
|
||||
const [showFinished, setShowFinished] = useState<{
|
||||
[fileId: string]: boolean;
|
||||
@@ -78,6 +79,11 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
// Add a ref to store the previous showFinished state
|
||||
const prevShowFinishedRef = useRef<{ [fileId: string]: boolean }>({});
|
||||
|
||||
// Add save pending status - Used for manual saving on non-Chrome browsers
|
||||
const [pendingSave, setPendingSave] = useState<{
|
||||
[fileId: string]: boolean;
|
||||
}>({});
|
||||
|
||||
const [pickedLocation, setPickedLocation] = useState<boolean>(false); // Whether a save directory has been selected
|
||||
const [needPickLocation, setNeedPickLocation] = useState<boolean>(false); // Whether a save directory needs to be selected -- for large files, folders, or user choice
|
||||
|
||||
@@ -92,6 +98,19 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
[fileId: string]: number;
|
||||
}>({});
|
||||
|
||||
// Handling manual save - for non-Chrome browsers
|
||||
const handleManualSave = (item: FileMeta) => {
|
||||
if (onDownload) {
|
||||
onDownload(item);
|
||||
// Clear the pending save state to display UI as "Completed"
|
||||
setPendingSave((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[item.fileId];
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDictionary(locale)
|
||||
.then((dict) => setMessages(dict))
|
||||
@@ -185,7 +204,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
delete updated[fileId];
|
||||
return updated;
|
||||
});
|
||||
// 根据模式清理对应的progress数据
|
||||
// Clean the corresponding progress data according to the pattern
|
||||
if (mode === "sender") {
|
||||
clearSendProgress(fileId, activePeerId);
|
||||
} else {
|
||||
@@ -206,7 +225,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, 3000);
|
||||
}, 3002);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -228,7 +247,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
const prevShowFinished = prevShowFinishedRef.current[item.fileId];
|
||||
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
|
||||
|
||||
// 添加详细调试信息
|
||||
const fileProgress = fileProgresses[item.fileId];
|
||||
const activePeerId = activeTransfers[item.fileId];
|
||||
const currentProgress = activePeerId
|
||||
@@ -237,10 +255,37 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
// Detecting false -> true transitions
|
||||
if (!prevShowFinished && currentShowFinished) {
|
||||
if (!isSaveToDisk && onDownload) {
|
||||
onDownload(item);
|
||||
const isAutoDownloadSupported = supportsAutoDownload();
|
||||
|
||||
if (isAutoDownloadSupported) {
|
||||
// Browsers that support automatic downloads like Chrome: Download directly
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[Download Debug] Auto-downloading file: ${item.name}`
|
||||
);
|
||||
}
|
||||
onDownload(item);
|
||||
} else {
|
||||
// Non-Chrome browsers: Set to save status, wait for user manual click
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[Download Debug] Setting pendingSave for non-Chrome browser: ${item.name}`
|
||||
);
|
||||
}
|
||||
setPendingSave((prev) => ({
|
||||
...prev,
|
||||
[item.fileId]: true,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increase download count - 文件传输完成时增加下载次数 (只计算一次)
|
||||
// Increase download count - Increment download count upon completion of file transfer (counted only once)
|
||||
setDownloadCounts((prevCounts) => ({
|
||||
...prevCounts,
|
||||
[item.fileId]: (prevCounts[item.fileId] || 0) + 1,
|
||||
@@ -250,22 +295,17 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
// Update the last status
|
||||
prevShowFinishedRef.current[item.fileId] = currentShowFinished;
|
||||
});
|
||||
}, [
|
||||
showFinished,
|
||||
singleFiles,
|
||||
folders,
|
||||
saveType,
|
||||
onDownload,
|
||||
downloadCounts,
|
||||
]);
|
||||
}, [showFinished, singleFiles, folders, saveType, onDownload]);
|
||||
|
||||
//Actions corresponding to each file - progress, download, delete
|
||||
const renderItemActions = (item: FileMeta) => {
|
||||
const fileProgress = fileProgresses[item.fileId];
|
||||
const activePeerId = activeTransfers[item.fileId];
|
||||
const progress = activePeerId ? fileProgress?.[activePeerId] : null;
|
||||
const showCompletion = showFinished[item.fileId];
|
||||
const showCompletion =
|
||||
showFinished[item.fileId] && !pendingSave[item.fileId]; // Only display completed when the transfer is finished and not in the save pending state
|
||||
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
|
||||
const isPendingSave = pendingSave[item.fileId] || false;
|
||||
// Get download count
|
||||
const downloadCount = downloadCounts[item.fileId] || 0;
|
||||
|
||||
@@ -297,6 +337,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
onDownload && ( //Request && Download
|
||||
<FileTransferButton
|
||||
onRequest={() => onRequest(item)}
|
||||
onSave={() => handleManualSave(item)}
|
||||
isCurrentFileTransferring={
|
||||
progress
|
||||
? progress.progress > 0 && progress.progress < 1
|
||||
@@ -304,6 +345,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
}
|
||||
isOtherFileTransferring={isAnyFileTransferring && !progress}
|
||||
isSavedToDisk={saveType ? saveType[item.fileId] : false}
|
||||
isPendingSave={isPendingSave}
|
||||
/>
|
||||
)}
|
||||
{/* display download Num*/}
|
||||
@@ -352,7 +394,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-gray-100 rounded-lg"
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-border rounded-lg"
|
||||
>
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -362,7 +404,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
? `${item.name.slice(0, filenameDisplayLen - 3)}...`
|
||||
: item.name}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-500">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">
|
||||
{isFolder
|
||||
? `${formatFolderDis(
|
||||
messages!.text.FileListDisplay.folder_dis_template,
|
||||
@@ -415,29 +457,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
{messages.text.FileListDisplay.chooseSavePath_dis}
|
||||
</Button>
|
||||
)}
|
||||
{/* Safe Save Button - only show when location is picked and files are saved to disk */}
|
||||
{onSafeSave &&
|
||||
pickedLocation &&
|
||||
(isAnyFileTransferring ||
|
||||
(saveType &&
|
||||
Object.values(saveType).some(
|
||||
(isSavedToDisk) => isSavedToDisk
|
||||
))) && (
|
||||
<Tooltip
|
||||
content={messages.text.FileListDisplay.safeSave_tooltip}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSafeSave();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mr-2 text-green-600 border-green-600 hover:bg-green-50"
|
||||
>
|
||||
{messages.text.FileListDisplay.safeSave_dis}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,22 +13,28 @@ import type { Messages } from "@/types/messages";
|
||||
|
||||
interface FileTransferButtonProps {
|
||||
onRequest: () => void;
|
||||
onSave?: () => void; // 新增:处理手动保存
|
||||
isCurrentFileTransferring: boolean;
|
||||
isOtherFileTransferring: boolean;
|
||||
isSavedToDisk: boolean;
|
||||
isPendingSave?: boolean; // 新增:是否待保存状态
|
||||
}
|
||||
// Manage buttons for different download statuses
|
||||
const FileTransferButton = ({
|
||||
onRequest,
|
||||
onSave,
|
||||
isCurrentFileTransferring,
|
||||
isOtherFileTransferring,
|
||||
isSavedToDisk,
|
||||
isPendingSave = false,
|
||||
}: FileTransferButtonProps) => {
|
||||
const locale = useLocale();
|
||||
const [messages, setMessages] = useState<Messages | null>(null);
|
||||
// Button status judgment
|
||||
// Button status judgment - 待保存状态时按钮应该可点击
|
||||
const isDisabled =
|
||||
isCurrentFileTransferring || isSavedToDisk || isOtherFileTransferring;
|
||||
isCurrentFileTransferring ||
|
||||
isSavedToDisk ||
|
||||
(isOtherFileTransferring && !isPendingSave);
|
||||
|
||||
useEffect(() => {
|
||||
getDictionary(locale)
|
||||
@@ -41,6 +47,8 @@ const FileTransferButton = ({
|
||||
return messages!.text.FileTransferButton.SavedToDisk_tips;
|
||||
if (isCurrentFileTransferring)
|
||||
return messages!.text.FileTransferButton.CurrentFileTransferring_tips;
|
||||
if (isPendingSave)
|
||||
return messages!.text.FileTransferButton.PendingSave_tips;
|
||||
if (isOtherFileTransferring)
|
||||
return messages!.text.FileTransferButton.OtherFileTransferring_tips;
|
||||
return messages!.text.FileTransferButton.download_tips;
|
||||
@@ -51,7 +59,7 @@ const FileTransferButton = ({
|
||||
if (isSavedToDisk) {
|
||||
return {
|
||||
variant: "ghost" as const,
|
||||
className: "mr-2 text-gray-500",
|
||||
className: "mr-2 text-muted-foreground",
|
||||
};
|
||||
}
|
||||
if (isCurrentFileTransferring) {
|
||||
@@ -60,16 +68,21 @@ const FileTransferButton = ({
|
||||
className: "mr-2 cursor-not-allowed",
|
||||
};
|
||||
}
|
||||
if (isPendingSave) {
|
||||
return {
|
||||
variant: "default" as const,
|
||||
className: "mr-2",
|
||||
};
|
||||
}
|
||||
if (isOtherFileTransferring) {
|
||||
return {
|
||||
variant: "outline" as const,
|
||||
className:
|
||||
"mr-2 cursor-not-allowed bg-gray-100 border-gray-300 text-gray-500",
|
||||
className: "mr-2 cursor-not-allowed bg-muted text-muted-foreground",
|
||||
};
|
||||
}
|
||||
return {
|
||||
variant: "outline" as const,
|
||||
className: "mr-2 hover:bg-blue-50",
|
||||
className: "mr-2 hover:bg-accent",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -83,7 +96,7 @@ const FileTransferButton = ({
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
onClick={onRequest}
|
||||
onClick={isPendingSave && onSave ? onSave : onRequest}
|
||||
variant={buttonStyles.variant}
|
||||
size="sm"
|
||||
className={buttonStyles.className}
|
||||
@@ -91,21 +104,20 @@ const FileTransferButton = ({
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
isOtherFileTransferring ? "opacity-50" : ""
|
||||
isOtherFileTransferring && !isPendingSave ? "opacity-50" : ""
|
||||
}`}
|
||||
/>
|
||||
{isSavedToDisk
|
||||
? messages.text.FileTransferButton.Saved_dis
|
||||
: isPendingSave
|
||||
? messages.text.FileTransferButton.Save_dis
|
||||
: isOtherFileTransferring
|
||||
? messages.text.FileTransferButton.Waiting_dis
|
||||
: messages.text.FileTransferButton.Download_dis}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="bg-gray-800 text-white px-3 py-2 rounded-md text-sm"
|
||||
>
|
||||
<TooltipContent side="top" className="px-3 py-2 rounded-md text-sm">
|
||||
{getTooltipContent()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -160,14 +160,14 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer"
|
||||
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer"
|
||||
onClick={handleZoneClick}
|
||||
>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{messages.text.fileUploadHandler.chooseFileTips}
|
||||
</p>
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-blue-500" />
|
||||
<p className="text-sm text-gray-600">{fileText}</p>
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{fileText}</p>
|
||||
|
||||
<Input
|
||||
id="file-upload"
|
||||
@@ -202,13 +202,13 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
<div className="flex justify-center gap-4 mt-6">
|
||||
<button
|
||||
onClick={handleSelectFile}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
className="px-4 py-2 rounded transition-colors bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{messages.text.fileUploadHandler.SelectFile_dis}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectFolder}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
className="px-4 py-2 rounded transition-colors bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
>
|
||||
{messages.text.fileUploadHandler.SelectFolder_dis}
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import RichTextEditor from "@/components/Editor/RichTextEditor";
|
||||
import {
|
||||
ReadClipboardButton,
|
||||
WriteClipboardButton,
|
||||
} from "@/components/common/clipboard_btn";
|
||||
import CachedIdActionButton from "@/components/ClipboardApp/CachedIdActionButton";
|
||||
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import type { FileMeta } from "@/types/webrtc";
|
||||
import type { ProgressState } from "@/hooks/useWebRTCConnection"; // Assuming this type is exported
|
||||
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
|
||||
@@ -32,7 +31,6 @@ interface RetrieveTabPanelProps {
|
||||
directoryHandle: FileSystemDirectoryHandle
|
||||
) => Promise<void>;
|
||||
getReceiverSaveType: () => { [fileId: string]: boolean } | undefined;
|
||||
manualSafeSave: () => void; // Add manual safe save function
|
||||
retrieveMessage: string;
|
||||
handleLeaveRoom: () => void;
|
||||
}
|
||||
@@ -49,11 +47,10 @@ export function RetrieveTabPanel({
|
||||
requestFolder,
|
||||
setReceiverDirectoryHandle,
|
||||
getReceiverSaveType,
|
||||
manualSafeSave,
|
||||
retrieveMessage,
|
||||
handleLeaveRoom,
|
||||
}: RetrieveTabPanelProps) {
|
||||
// 从 store 中获取状态
|
||||
// Get the status from the store
|
||||
const {
|
||||
retrieveRoomStatusText,
|
||||
retrieveRoomIdInput,
|
||||
@@ -61,38 +58,25 @@ export function RetrieveTabPanel({
|
||||
retrievedFileMetas,
|
||||
receiveProgress,
|
||||
isAnyFileTransferring,
|
||||
senderDisconnected,
|
||||
isReceiverInRoom,
|
||||
} = useFileTransferStore();
|
||||
|
||||
const onLocationPick = useCallback(async (): Promise<boolean> => {
|
||||
if (!messages) return false; // Should not happen if panel is rendered
|
||||
if (!window.showDirectoryPicker) {
|
||||
putMessageInMs(
|
||||
// messages.text.ClipboardApp.pickSaveUnsupported ||
|
||||
"Directory picker not supported.",
|
||||
false
|
||||
);
|
||||
putMessageInMs(messages.text.ClipboardApp.pickSaveUnsupported, false);
|
||||
return false;
|
||||
}
|
||||
if (!window.confirm(messages.text.ClipboardApp.pickSaveMsg)) return false;
|
||||
try {
|
||||
const directoryHandle = await window.showDirectoryPicker();
|
||||
await setReceiverDirectoryHandle(directoryHandle);
|
||||
putMessageInMs(
|
||||
// messages.text.ClipboardApp.pickSaveSuccess ||
|
||||
"Save location set.",
|
||||
false
|
||||
);
|
||||
putMessageInMs(messages.text.ClipboardApp.pickSaveSuccess, false);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("Failed to set up folder receive:", err);
|
||||
putMessageInMs(
|
||||
// messages.text.ClipboardApp.pickSaveError ||
|
||||
"Could not set save location.",
|
||||
false
|
||||
);
|
||||
putMessageInMs(messages.text.ClipboardApp.pickSaveError, false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -114,7 +98,7 @@ export function RetrieveTabPanel({
|
||||
|
||||
return (
|
||||
<div id="retrieve-panel" role="tabpanel" aria-labelledby="retrieve-tab">
|
||||
<div className="mb-3 text-sm text-gray-600">
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
{retrieveRoomStatusText ||
|
||||
(isReceiverInRoom
|
||||
? messages.text.ClipboardApp.roomStatus.connected_dis
|
||||
@@ -128,6 +112,14 @@ export function RetrieveTabPanel({
|
||||
title={messages.text.ClipboardApp.html.readClipboard_dis}
|
||||
onRead={setRetrieveRoomIdInput}
|
||||
/>
|
||||
{/* Save/Use Cached ID Button placed after Paste button */}
|
||||
<CachedIdActionButton
|
||||
messages={messages}
|
||||
getInputValue={() => retrieveRoomIdInput}
|
||||
setInputValue={setRetrieveRoomIdInput}
|
||||
putMessageInMs={putMessageInMs}
|
||||
isShareEnd={false}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Retrieve Room ID"
|
||||
value={retrieveRoomIdInput}
|
||||
@@ -151,18 +143,20 @@ export function RetrieveTabPanel({
|
||||
{messages.text.ClipboardApp.html.joinRoom_dis}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||
onClick={handleLeaveRoom}
|
||||
disabled={!isReceiverInRoom || isAnyFileTransferring}
|
||||
disabled={!isReceiverInRoom}
|
||||
className="w-full sm:w-auto px-4 order-2"
|
||||
>
|
||||
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
{isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{retrievedContent && (
|
||||
<div className="my-3 p-3 border rounded-md">
|
||||
<div className="bg-white p-3 rounded border border-gray-200 text-sm leading-relaxed">
|
||||
<div className="bg-card text-card-foreground p-3 rounded border text-sm leading-relaxed">
|
||||
<div dangerouslySetInnerHTML={{ __html: retrievedContent }} />
|
||||
</div>
|
||||
<div className="flex justify-start">
|
||||
@@ -181,11 +175,10 @@ export function RetrieveTabPanel({
|
||||
onDownload={handleDownloadFile}
|
||||
onRequest={handleFileRequestFromPanel} // Use the panel's own handler
|
||||
onLocationPick={onLocationPick} // Use the panel's own handler
|
||||
onSafeSave={manualSafeSave} // Add safe save handler
|
||||
saveType={getReceiverSaveType()}
|
||||
/>
|
||||
{retrieveMessage && (
|
||||
<p className="mt-3 text-sm text-blue-600">{retrieveMessage}</p>
|
||||
<p className="mt-3 text-sm text-primary">{retrieveMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import CachedIdActionButton from "@/components/ClipboardApp/CachedIdActionButton";
|
||||
import {
|
||||
ReadClipboardButton,
|
||||
WriteClipboardButton,
|
||||
@@ -11,7 +12,6 @@ import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
||||
import AnimatedButton from "@/components/ui/AnimatedButton";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import type { CustomFile, FileMeta } from "@/types/webrtc";
|
||||
import type { ProgressState } from "@/hooks/useWebRTCConnection";
|
||||
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
|
||||
@@ -21,7 +21,7 @@ const RichTextEditor = dynamic(
|
||||
{
|
||||
ssr: false, // This component is client-side only
|
||||
loading: () => (
|
||||
<div className="p-4 border rounded-lg min-h-[200px] md:min-h-[400px] bg-gray-50 flex items-center justify-center">
|
||||
<div className="p-4 border rounded-lg min-h-[200px] md:min-h-[400px] bg-muted flex items-center justify-center">
|
||||
Loading Editor...
|
||||
</div>
|
||||
),
|
||||
@@ -40,6 +40,11 @@ interface SendTabPanelProps {
|
||||
shareMessage: string;
|
||||
currentValidatedShareRoomId: string;
|
||||
handleLeaveSenderRoom: () => void; // New prop for leaving room
|
||||
putMessageInMs: (
|
||||
message: string,
|
||||
isShareEnd?: boolean,
|
||||
displayTimeMs?: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function SendTabPanel({
|
||||
@@ -54,8 +59,9 @@ export function SendTabPanel({
|
||||
shareMessage,
|
||||
currentValidatedShareRoomId,
|
||||
handleLeaveSenderRoom,
|
||||
putMessageInMs,
|
||||
}: SendTabPanelProps) {
|
||||
// 从 store 中获取状态
|
||||
// Get the status from the store
|
||||
const {
|
||||
shareContent,
|
||||
sendFiles,
|
||||
@@ -68,6 +74,8 @@ export function SendTabPanel({
|
||||
const [inputFieldValue, setInputFieldValue] = useState<string>(
|
||||
currentValidatedShareRoomId
|
||||
);
|
||||
// State to track ID generation mode (false = will show simple next, true = will show random next)
|
||||
const [isSimpleIdMode, setIsSimpleIdMode] = useState<boolean>(true);
|
||||
|
||||
// When the validatedShareRoomId from the parent component changes (e.g., after initial fetch), synchronize the local input field's value
|
||||
useEffect(() => {
|
||||
@@ -92,9 +100,43 @@ export function SendTabPanel({
|
||||
[processRoomIdInput]
|
||||
);
|
||||
|
||||
// Handle ID generation toggle
|
||||
const handleIdGeneration = useCallback(async () => {
|
||||
if (isSimpleIdMode) {
|
||||
// Generate random UUID
|
||||
const randomId = crypto.randomUUID();
|
||||
processRoomIdInput(randomId);
|
||||
} else {
|
||||
// Generate simple 4-digit ID by calling backend API
|
||||
try {
|
||||
const { fetchRoom } = await import("@/app/config/api");
|
||||
const simpleRoomId = await fetchRoom();
|
||||
if (simpleRoomId) {
|
||||
// fetchRoom() already created the room, so set it as initial room ID
|
||||
// This prevents joinRoom() from trying to create it again
|
||||
setInputFieldValue(simpleRoomId);
|
||||
const { useFileTransferStore } = await import(
|
||||
"@/stores/fileTransferStore"
|
||||
);
|
||||
const store = useFileTransferStore.getState();
|
||||
store.setShareRoomId(simpleRoomId);
|
||||
// IMPORTANT: Set as initial room ID to prevent duplicate creation
|
||||
store.setInitShareRoomId(simpleRoomId);
|
||||
} else {
|
||||
processRoomIdInput(crypto.randomUUID());
|
||||
}
|
||||
} catch (error) {
|
||||
processRoomIdInput(crypto.randomUUID());
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mode for next click
|
||||
setIsSimpleIdMode(!isSimpleIdMode);
|
||||
}, [isSimpleIdMode, processRoomIdInput, setInputFieldValue]);
|
||||
|
||||
return (
|
||||
<div id="send-panel" role="tabpanel" aria-labelledby="send-tab">
|
||||
<div className="mb-3 text-sm text-gray-600">
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
{shareRoomStatusText ||
|
||||
(isSenderInRoom
|
||||
? messages.text.ClipboardApp.roomStatus.onlyOneMsg
|
||||
@@ -124,7 +166,7 @@ export function SendTabPanel({
|
||||
<div className="space-y-3 mb-4">
|
||||
{/* Room ID input section */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{messages.text.ClipboardApp.html.inputRoomId_tips}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
@@ -141,11 +183,26 @@ export function SendTabPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto px-4"
|
||||
onClick={() => processRoomIdInput(crypto.randomUUID())}
|
||||
onClick={handleIdGeneration}
|
||||
disabled={isSenderInRoom}
|
||||
>
|
||||
{messages.text.ClipboardApp.html.generateRoomId_tips}
|
||||
{isSimpleIdMode
|
||||
? messages.text.ClipboardApp.html.generateRandomId_tips
|
||||
: messages.text.ClipboardApp.html.generateSimpleId_tips}
|
||||
</Button>
|
||||
{/* Save/Use Cached ID Button in between */}
|
||||
<CachedIdActionButton
|
||||
messages={messages}
|
||||
getInputValue={() => inputFieldValue}
|
||||
setInputValue={setInputFieldValue}
|
||||
putMessageInMs={putMessageInMs}
|
||||
isShareEnd={true}
|
||||
disabled={isSenderInRoom}
|
||||
onUseCached={(id) => {
|
||||
// Immediately join as sender after applying cached ID
|
||||
joinRoom(true, id.trim());
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="w-full sm:w-auto px-4"
|
||||
onClick={() => joinRoom(true, inputFieldValue.trim())}
|
||||
@@ -174,17 +231,19 @@ export function SendTabPanel({
|
||||
{messages.text.ClipboardApp.html.SyncSending_dis}
|
||||
</AnimatedButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||
onClick={handleLeaveSenderRoom}
|
||||
disabled={!isSenderInRoom || isAnyFileTransferring}
|
||||
disabled={!isSenderInRoom}
|
||||
className="w-full sm:w-auto px-4 order-2"
|
||||
>
|
||||
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
{isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{shareMessage && (
|
||||
<p className="mt-3 text-sm text-blue-600">{shareMessage}</p>
|
||||
<p className="mt-3 text-sm text-primary">{shareMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -120,21 +120,21 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
return (
|
||||
<div className="bg-blue-50 p-2 sm:p-4 rounded-lg border border-blue-200">
|
||||
<p className="text-blue-700 mb-3 sm:mb-4 text-sm sm:text-base">
|
||||
<div className="bg-primary/10 p-2 sm:p-4 rounded-lg border border-primary/20">
|
||||
<p className="text-primary mb-3 sm:mb-4 text-sm sm:text-base">
|
||||
{messages.text.RetrieveMethod.P}
|
||||
</p>
|
||||
|
||||
{/* Mobile-first responsive layout */}
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* RoomID section */}
|
||||
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
|
||||
<div className="bg-card p-2 sm:p-3 rounded-lg border border-border">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{messages.text.RetrieveMethod.RoomId_tips}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<code className="flex-1 bg-gray-100 px-2 py-1 rounded text-sm font-mono break-all">
|
||||
<code className="flex-1 bg-muted px-2 py-1 rounded text-sm font-mono break-all">
|
||||
{RoomID}
|
||||
</code>
|
||||
<WriteClipboardButton
|
||||
@@ -146,12 +146,12 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
|
||||
</div>
|
||||
|
||||
{/* URL section */}
|
||||
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
|
||||
<div className="bg-card p-2 sm:p-3 rounded-lg border border-border">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{messages.text.RetrieveMethod.url_tips}
|
||||
</p>
|
||||
<div className="bg-gray-100 px-2 py-2 rounded text-xs sm:text-sm break-all font-mono">
|
||||
<div className="bg-muted px-2 py-2 rounded text-xs sm:text-sm break-all font-mono">
|
||||
{shareLink}
|
||||
</div>
|
||||
<div className="flex justify-start">
|
||||
@@ -164,15 +164,15 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
|
||||
</div>
|
||||
|
||||
{/* QR Code section */}
|
||||
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
|
||||
<div className="bg-card p-2 sm:p-3 rounded-lg border border-border">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{messages.text.RetrieveMethod.scanQR_tips}
|
||||
</p>
|
||||
|
||||
{/* QR Code display area - moved up for better mobile UX */}
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-block border-2 p-2 sm:p-4 bg-gray-50 rounded-lg">
|
||||
<div className="inline-block border-2 p-2 sm:p-4 bg-muted rounded-lg">
|
||||
<div ref={qrRef}>
|
||||
<QRCodeSVG
|
||||
value={shareLink}
|
||||
|
||||
@@ -9,21 +9,21 @@ export function AlignmentTools({ alignText }: AlignmentToolsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
className="p-1.5 hover:bg-accent rounded"
|
||||
onClick={() => alignText("left")}
|
||||
title="Align left"
|
||||
>
|
||||
<AlignLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
className="p-1.5 hover:bg-accent rounded"
|
||||
onClick={() => alignText("center")}
|
||||
title="Align center"
|
||||
>
|
||||
<AlignCenter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
className="p-1.5 hover:bg-accent rounded"
|
||||
onClick={() => alignText("right")}
|
||||
title="Align right"
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function BasicFormatTools({
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
className={`p-1.5 rounded ${
|
||||
isStyleActive("bold") ? "bg-gray-200" : "hover:bg-gray-200"
|
||||
isStyleActive("bold") ? "bg-accent" : "hover:bg-accent"
|
||||
}`}
|
||||
onClick={() => formatText("bold")}
|
||||
title="Bold"
|
||||
@@ -23,7 +23,7 @@ export function BasicFormatTools({
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded ${
|
||||
isStyleActive("italic") ? "bg-gray-200" : "hover:bg-gray-200"
|
||||
isStyleActive("italic") ? "bg-accent" : "hover:bg-accent"
|
||||
}`}
|
||||
onClick={() => formatText("italic")}
|
||||
title="Italic"
|
||||
@@ -32,7 +32,7 @@ export function BasicFormatTools({
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded ${
|
||||
isStyleActive("underline") ? "bg-gray-200" : "hover:bg-gray-200"
|
||||
isStyleActive("underline") ? "bg-accent" : "hover:bg-accent"
|
||||
}`}
|
||||
onClick={() => formatText("underline")}
|
||||
title="Underline"
|
||||
|
||||
@@ -14,21 +14,21 @@ export function InsertTools({
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
className="p-1.5 hover:bg-accent rounded"
|
||||
onClick={insertLink}
|
||||
title="Insert url"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
className="p-1.5 hover:bg-accent rounded"
|
||||
onClick={insertImage}
|
||||
title="Upload image"
|
||||
>
|
||||
<Image className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
className="p-1.5 hover:bg-accent rounded"
|
||||
onClick={insertCodeBlock}
|
||||
title="Insert code"
|
||||
>
|
||||
|
||||
@@ -129,7 +129,7 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = "" }) => {
|
||||
<div className="w-full space-x-2 mb-4">
|
||||
<div className="border rounded-lg shadow-sm overflow-hidden">
|
||||
{/* Toolbar - Add light gray background and bottom border */}
|
||||
<div className="flex flex-wrap gap-1 p-2 bg-gray-50 border-b">
|
||||
<div className="flex flex-wrap gap-1 p-2 bg-muted border-b border-border">
|
||||
{/* Basic format tool group */}
|
||||
<BasicFormatTools
|
||||
isStyleActive={isStyleActive}
|
||||
@@ -158,10 +158,10 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = "" }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor area - Add pure white background and inner shadow effect */}
|
||||
{/* Editor area - use theme tokens for background */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-white shadow-inner"
|
||||
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-card shadow-inner"
|
||||
contentEditable
|
||||
onPaste={handlePaste}
|
||||
onInput={handleChange}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SelectMenu: React.FC<SelectMenuProps> = ({
|
||||
}) => (
|
||||
<div className="relative inline-block">
|
||||
<select
|
||||
className={`appearance-none bg-transparent border rounded p-1.5 pr-6 hover:bg-gray-200 focus:outline-none ${className}`}
|
||||
className={`appearance-none bg-transparent border border-border rounded p-1.5 pr-6 hover:bg-accent focus:outline-none ${className}`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { type BlogPost } from "@/lib/blog";
|
||||
import { Messages } from "@/types/messages";
|
||||
|
||||
interface ArticleListItemProps {
|
||||
post: BlogPost;
|
||||
lang: string;
|
||||
messages: Messages;
|
||||
}
|
||||
|
||||
export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
||||
export function ArticleListItem({ post, lang, messages }: ArticleListItemProps) {
|
||||
return (
|
||||
<article className="bg-white rounded-xl shadow-lg hover:shadow-xl transition-shadow overflow-hidden">
|
||||
<article className="bg-card rounded-xl shadow-lg hover:shadow-xl transition-shadow overflow-hidden">
|
||||
<div className="relative h-80 w-full">
|
||||
<Image
|
||||
src={post.frontmatter.cover}
|
||||
@@ -22,16 +24,20 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<time className="font-medium">
|
||||
{new Date(post.frontmatter.date).toLocaleDateString()}
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(lang, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
<span>·</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{post.frontmatter.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200 transition-colors"
|
||||
className="bg-muted px-3 py-1 rounded-full hover:bg-accent transition-colors"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -39,21 +45,21 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/${lang}/blog/${post.slug}`}>
|
||||
<h2 className="text-3xl font-bold mb-4 hover:text-blue-600 transition-colors leading-tight">
|
||||
<h2 className="text-3xl font-bold mb-4 hover:text-primary transition-colors leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h2>
|
||||
</Link>
|
||||
|
||||
<p className="text-gray-600 mb-6 text-lg leading-relaxed line-clamp-3">
|
||||
<p className="text-muted-foreground mb-6 text-lg leading-relaxed line-clamp-3">
|
||||
{post.frontmatter.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<Link
|
||||
href={`/${lang}/blog/${post.slug}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium inline-flex items-center text-lg"
|
||||
className="text-primary hover:text-primary/80 font-medium inline-flex items-center text-lg"
|
||||
>
|
||||
Read more
|
||||
{messages.text.blog.read_more}
|
||||
<svg
|
||||
className="w-5 h-5 ml-2"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -71,7 +77,7 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">
|
||||
by <span className="font-bold">{post.frontmatter.author}</span>
|
||||
{messages.text.blog.by} <span className="font-bold">{post.frontmatter.author}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export type MDXComponents = {
|
||||
// Custom MDX components
|
||||
export const mdxComponents: MDXComponents = {
|
||||
p: ({ children, ...props }) => (
|
||||
<div className="mb-6 leading-relaxed text-gray-700" {...props}>
|
||||
<div className="mb-6 leading-relaxed text-foreground" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
@@ -92,7 +92,7 @@ export const mdxComponents: MDXComponents = {
|
||||
alt={props.alt || ""}
|
||||
/>
|
||||
{props.alt && (
|
||||
<div className="text-center text-sm text-gray-600 mt-2 italic">
|
||||
<div className="text-center text-sm text-muted-foreground mt-2 italic">
|
||||
{props.alt}
|
||||
</div>
|
||||
)}
|
||||
@@ -101,7 +101,7 @@ export const mdxComponents: MDXComponents = {
|
||||
},
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
className="relative my-6 rounded-lg bg-gray-50 border border-gray-200 p-4 overflow-x-auto"
|
||||
className="relative my-6 rounded-lg bg-muted border border-border p-4 overflow-x-auto"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -111,13 +111,13 @@ export const mdxComponents: MDXComponents = {
|
||||
const isInlineCode = !className;
|
||||
return isInlineCode ? (
|
||||
<code
|
||||
className="bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200 text-sm"
|
||||
className="bg-muted rounded px-1.5 py-0.5 text-foreground border border-border text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="block text-gray-800 text-sm" {...props}>
|
||||
<code className="block text-foreground text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
@@ -125,7 +125,7 @@ export const mdxComponents: MDXComponents = {
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-8 w-full overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full divide-y divide-gray-300 border border-gray-300"
|
||||
className="min-w-full divide-y divide-border border border-border"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -133,23 +133,23 @@ export const mdxComponents: MDXComponents = {
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }) => (
|
||||
<thead className="bg-gray-50" {...props}>
|
||||
<thead className="bg-muted" {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children, ...props }) => (
|
||||
<tbody className="divide-y divide-gray-200 bg-white" {...props}>
|
||||
<tbody className="divide-y divide-border bg-card" {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children, ...props }) => (
|
||||
<tr className="hover:bg-gray-50" {...props}>
|
||||
<tr className="hover:bg-accent" {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children, ...props }) => (
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-r last:border-r-0"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider border-r last:border-r-0"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -157,7 +157,7 @@ export const mdxComponents: MDXComponents = {
|
||||
),
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 border-r last:border-r-0"
|
||||
className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground border-r last:border-r-0"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -165,7 +165,7 @@ export const mdxComponents: MDXComponents = {
|
||||
),
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-blue-500 pl-4 my-4 italic text-gray-600 bg-gray-50 py-2 rounded-r-lg"
|
||||
className="border-l-4 border-primary pl-4 my-4 italic text-muted-foreground bg-muted py-2 rounded-r-lg"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -173,7 +173,7 @@ export const mdxComponents: MDXComponents = {
|
||||
),
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul
|
||||
className="list-disc list-outside ml-6 my-6 space-y-2 text-gray-700"
|
||||
className="list-disc list-outside ml-6 my-6 space-y-2 text-foreground"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -181,7 +181,7 @@ export const mdxComponents: MDXComponents = {
|
||||
),
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol
|
||||
className="list-decimal list-outside ml-6 my-6 space-y-2 text-gray-700"
|
||||
className="list-decimal list-outside ml-6 my-6 space-y-2 text-foreground"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -10,10 +10,12 @@ interface TocItem {
|
||||
|
||||
interface TableOfContentsProps {
|
||||
content: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
||||
content,
|
||||
title = "Table of contents",
|
||||
}) => {
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
const [toc, setToc] = useState<TocItem[]>([]);
|
||||
@@ -109,8 +111,8 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
||||
if (toc.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:block sticky top-8 p-6 bg-gray-50 rounded-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<h4 className="text-lg font-semibold mb-4">Table of contents</h4>
|
||||
<nav className="hidden lg:block sticky top-8 p-6 bg-muted rounded-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<h4 className="text-lg font-semibold mb-4">{title}</h4>
|
||||
<ul className="space-y-2">
|
||||
{toc.map((item) => (
|
||||
<li
|
||||
@@ -123,10 +125,10 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
||||
<button
|
||||
onClick={() => scrollToHeader(item.id)}
|
||||
className={clsx(
|
||||
"block w-full text-left py-1 text-sm hover:text-blue-600 transition-colors",
|
||||
"block w-full text-left py-1 text-sm hover:text-primary transition-colors",
|
||||
activeId === item.id
|
||||
? "text-blue-600 font-medium"
|
||||
: "text-gray-600"
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
type JsonLdProps = {
|
||||
data: Record<string, any> | Record<string, any>[];
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export default function JsonLd({ data, id }: JsonLdProps) {
|
||||
const blocks = Array.isArray(data) ? data : [data];
|
||||
return (
|
||||
<>
|
||||
{blocks.map((item, idx) => (
|
||||
<script
|
||||
key={id ? `${id}-${idx}` : idx}
|
||||
type="application/ld+json"
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(item) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Image from "next/image";
|
||||
import { Menu, X, Github } from "lucide-react";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { Messages } from "@/types/messages";
|
||||
import ThemeToggle from "@/components/web/ThemeToggle";
|
||||
|
||||
/**
|
||||
* Props interface for the Header component
|
||||
@@ -91,6 +92,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
|
||||
<Github className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,6 +100,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
|
||||
{/* Mobile menu controls */}
|
||||
<div className="md:hidden flex items-center space-x-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||
{messages.text.HowItWorks.h2}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8">{messages.text.HowItWorks.h2_P}</p>
|
||||
<p className="text-muted-foreground mb-8">{messages.text.HowItWorks.h2_P}</p>
|
||||
<Button className="bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 text-white rounded-full px-8 py-6 text-lg">
|
||||
{messages.text.HowItWorks.btn_try}
|
||||
</Button>
|
||||
@@ -60,7 +60,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold mb-2">{step.title}</h3>
|
||||
<p className="text-gray-600">{step.description}</p>
|
||||
<p className="text-muted-foreground">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -69,7 +69,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
|
||||
|
||||
{/* Right Side - Demo Animation */}
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="bg-card rounded-lg shadow-lg overflow-hidden">
|
||||
<video autoPlay loop muted playsInline width="1920" height="75">
|
||||
<source src="/HowItWorks.webm" type="video/webm" />
|
||||
</video>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const toggle = () => setTheme(isDark ? "light" : "dark");
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle theme"
|
||||
onClick={toggle}
|
||||
disabled={!mounted}
|
||||
>
|
||||
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ export const de: Messages = {
|
||||
description:
|
||||
"Überprüfen Sie die Nutzungsbedingungen für PrivyDrop, einschließlich Informationen zur akzeptablen Nutzung des Dienstes, Datenschutz und -sicherheit sowie Haftungsbeschränkungen.",
|
||||
},
|
||||
blog: {
|
||||
title:
|
||||
"PrivyDrop Blog - Private P2P-Dateifreigabe & Zusammenarbeit",
|
||||
description:
|
||||
"Entdecken Sie Tipps für sicheres Dateifreigeben, datenschutzorientierte Zusammenarbeit und wie man P2P und WebRTC für sicherere Übertragungen nutzt.",
|
||||
keywords:
|
||||
"sichere Dateifreigabe,p2p Dateiübertragung,private Zusammenarbeit,webrtc,Ende-zu-Ende-Verschlüsselung,Teamzusammenarbeit,Datenschutz-Tools",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header: {
|
||||
@@ -63,6 +71,19 @@ export const de: Messages = {
|
||||
Privacy_dis: "Datenschutzrichtlinie",
|
||||
SupportedLanguages: "Unterstützte Sprachen",
|
||||
},
|
||||
blog: {
|
||||
list_title: "Blog",
|
||||
list_subtitle: "Neueste Artikel und Updates",
|
||||
recent_posts: "Neueste Beiträge",
|
||||
tags: "Schlagwörter",
|
||||
read_more: "Weiterlesen",
|
||||
by: "von",
|
||||
post_not_found: "Beitrag nicht gefunden",
|
||||
toc_title: "Inhaltsverzeichnis",
|
||||
tag_title_prefix: "Schlagwort",
|
||||
tag_subtitle_template: "Artikel mit dem Schlagwort {tag}",
|
||||
tag_empty: "Keine Artikel für dieses Schlagwort gefunden.",
|
||||
},
|
||||
|
||||
privacy: {
|
||||
PrivacyPolicy_dis: "Datenschutzrichtlinie",
|
||||
@@ -235,9 +256,11 @@ export const de: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Bitte warten Sie, bis die aktuelle Übertragung abgeschlossen ist",
|
||||
download_tips: "Klicken Sie, um die Datei herunterzuladen",
|
||||
PendingSave_tips: "Klicken Sie, um die Datei lokal zu speichern", // 新增
|
||||
Saved_dis: "Gespeichert",
|
||||
Waiting_dis: "Warten",
|
||||
Download_dis: "Herunterladen",
|
||||
Save_dis: "Speichern", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Senden",
|
||||
@@ -254,11 +277,6 @@ export const de: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Speichern Sie große Dateien oder Ordner direkt in einem ausgewählten Verzeichnis. 👉",
|
||||
chooseSavePath_dis: "Speicherort auswählen",
|
||||
safeSave_dis: "Sicheres Speichern",
|
||||
safeSave_tooltip:
|
||||
"Keine Angst vor Verbindungsunterbrechung, klicken Sie hier, um Dateien sicher zu speichern für die nächste Fortsetzung",
|
||||
safeSaveSuccessMsg:
|
||||
"Dateien wurden sicher auf der Festplatte gespeichert, sicher die Seite zu schließen, unterstützt Wiederaufnahme der Übertragung!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "Glückwunsch 🎉 Freigegebene Inhalte warten darauf, abgerufen zu werden:",
|
||||
@@ -287,8 +305,7 @@ export const de: Messages = {
|
||||
"Warten auf den Empfänger, der sich verbindet. Bitte lassen Sie diese Seite geöffnet, bis die Übertragung abgeschlossen ist. Auf dem Desktop können Sie den Browser minimieren oder zwischen Tabs wechseln. Auf mobilen Geräten sollte der Browser im Vordergrund bleiben.",
|
||||
joinRoom: {
|
||||
EmptyMsg: "Warnung, die Raum-ID ist leer",
|
||||
DuplicateMsg:
|
||||
"Die eingegebene Raum-ID ist doppelt. Bitte geben Sie sie erneut ein.",
|
||||
DuplicateMsg: "Diese Raum-ID ist bereits vergeben. Bitte wählen Sie eine andere ID.",
|
||||
successMsg:
|
||||
"Raum erfolgreich betreten! Schließen Sie diese Seite nicht, bis die Übertragung abgeschlossen ist. (Am Desktop können Sie den Browser minimieren oder Tabs wechseln; auf mobilen Geräten bringen Sie den Browser nicht in den Hintergrund.)",
|
||||
notExist:
|
||||
@@ -296,6 +313,9 @@ export const de: Messages = {
|
||||
failMsg: "Fehler beim Beitreten zum Raum:",
|
||||
},
|
||||
pickSaveMsg: "Direkt auf Festplatte speichern?",
|
||||
pickSaveUnsupported: "Verzeichnisauswahl nicht unterstützt.",
|
||||
pickSaveSuccess: "Speicherort festgelegt.",
|
||||
pickSaveError: "Speicherort konnte nicht festgelegt werden.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "Raum ist leer",
|
||||
receiverEmptyMsg:
|
||||
@@ -311,6 +331,9 @@ export const de: Messages = {
|
||||
noFilesForFolderMsg: "Keine Dateien im Ordner '{folderName}' gefunden.",
|
||||
zipError: "Fehler beim Erstellen der ZIP-Datei.",
|
||||
fileNotFoundMsg: "Datei '{fileName}' zum Herunterladen nicht gefunden.",
|
||||
confirmLeaveWhileTransferring:
|
||||
"Übertragung wird unterbrochen. Bei Speicherverzeichnis kann fortgesetzt werden. Verlassen?",
|
||||
leaveWhileTransferringSuccess: "Raum verlassen, Übertragung unterbrochen",
|
||||
html: {
|
||||
senderTab: "Senden",
|
||||
retrieveTab: "Abrufen",
|
||||
@@ -321,7 +344,8 @@ export const de: Messages = {
|
||||
Copy_dis: "Kopieren",
|
||||
inputRoomIdprompt: "Ihre Raum-ID (bearbeitbar):",
|
||||
joinRoomBtn: "Raum beitreten",
|
||||
generateRoomId_tips: "Zufällige ID",
|
||||
generateSimpleId_tips: "Einfache ID",
|
||||
generateRandomId_tips: "Zufällige ID",
|
||||
readClipboardToRoomId: "Raum-ID einfügen",
|
||||
enterRoomID_placeholder: "Raum-ID eingeben",
|
||||
retrieveMethod: "Abrufmethode",
|
||||
@@ -332,7 +356,14 @@ export const de: Messages = {
|
||||
readClipboard_dis: "Raum-ID einfügen",
|
||||
retrieveRoomId_placeholder: "Raum-ID eingeben",
|
||||
RetrieveMethodTitle: "Abrufmethode",
|
||||
// New: cached ID utils
|
||||
saveId_dis: "ID speichern",
|
||||
useCachedId_dis: "Gespeicherte ID verwenden",
|
||||
saveId_tips: "Aktuelle ID für spätere schnelle Nutzung speichern",
|
||||
useCachedId_tips: "Gespeicherte ID schnell nutzen; Doppelklick zum Speichermodus wechseln",
|
||||
},
|
||||
// New: cache messages
|
||||
saveId_success: "Erfolgreich im Cache gespeichert",
|
||||
},
|
||||
home: {
|
||||
h1: "Kostenloses sicheres Online-Clipboard & Dateiübertragungstool",
|
||||
|
||||
@@ -45,6 +45,14 @@ export const en: Messages = {
|
||||
description:
|
||||
"Review the terms of use for PrivyDrop, including information about the acceptable use of the service, data privacy and security, and limitations of liability.",
|
||||
},
|
||||
blog: {
|
||||
title:
|
||||
"PrivyDrop Blog - Private P2P File Sharing & Collaboration",
|
||||
description:
|
||||
"Discover secure file sharing tips, privacy-first collaboration strategies, and how to leverage P2P and WebRTC for safer data transfer.",
|
||||
keywords:
|
||||
"secure file sharing,p2p file transfer,private collaboration,webrtc,end-to-end encryption,team collaboration,privacy tools",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header: {
|
||||
@@ -63,6 +71,19 @@ export const en: Messages = {
|
||||
Privacy_dis: "Privacy Policy",
|
||||
SupportedLanguages: "Supported Languages",
|
||||
},
|
||||
blog: {
|
||||
list_title: "Blog",
|
||||
list_subtitle: "Latest articles and updates",
|
||||
recent_posts: "Recent Posts",
|
||||
tags: "Tags",
|
||||
read_more: "Read more",
|
||||
by: "by",
|
||||
post_not_found: "Post not found",
|
||||
toc_title: "Table of contents",
|
||||
tag_title_prefix: "Tag",
|
||||
tag_subtitle_template: "Articles tagged with {tag}",
|
||||
tag_empty: "No articles found for this tag.",
|
||||
},
|
||||
|
||||
privacy: {
|
||||
PrivacyPolicy_dis: "Privacy Policy",
|
||||
@@ -232,9 +253,11 @@ export const en: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Please wait for current transfer to complete",
|
||||
download_tips: "Click to download file",
|
||||
PendingSave_tips: "Click to save file locally",
|
||||
Saved_dis: "Saved",
|
||||
Waiting_dis: "Waiting",
|
||||
Download_dis: "Download",
|
||||
Save_dis: "Save",
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Sending",
|
||||
@@ -251,11 +274,6 @@ export const en: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Save large files or folders directly to a selected directory. 👉",
|
||||
chooseSavePath_dis: "Choose save location",
|
||||
safeSave_dis: "Safe Save",
|
||||
safeSave_tooltip:
|
||||
"Don't worry about connection interruption, click here to safely save files for next resume",
|
||||
safeSaveSuccessMsg:
|
||||
"Files have been safely saved to disk, safe to close page, supports resume transfer!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "Congrats 🎉 Share content is waiting to be retrieved:",
|
||||
@@ -281,7 +299,7 @@ export const en: Messages = {
|
||||
"Waiting for receiver to connect. Please keep this page open until the transfer is complete. On desktop, you can minimize the browser or switch tabs. On mobile, please keep the browser in the foreground.",
|
||||
joinRoom: {
|
||||
EmptyMsg: "Warning, the roomID is empty",
|
||||
DuplicateMsg: "The room ID you entered is duplicate. Please re-enter.",
|
||||
DuplicateMsg: "This room ID is already in use. Please choose another ID.",
|
||||
successMsg:
|
||||
"Successfully joined the room! Do not close this page until the transfer is complete. (On desktop, you can minimize the browser or switch tabs; on mobile, do not move the browser to the background.)",
|
||||
notExist:
|
||||
@@ -289,6 +307,9 @@ export const en: Messages = {
|
||||
failMsg: "Failed to join room:",
|
||||
},
|
||||
pickSaveMsg: "Save Directly to Disk ?",
|
||||
pickSaveUnsupported: "Directory picker not supported.",
|
||||
pickSaveSuccess: "Save location set.",
|
||||
pickSaveError: "Could not set save location.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "Room is empty",
|
||||
receiverEmptyMsg: "You can accept an invitation to join the room",
|
||||
@@ -303,6 +324,8 @@ export const en: Messages = {
|
||||
noFilesForFolderMsg: "No files found for folder '{folderName}'.",
|
||||
zipError: "Error creating ZIP.",
|
||||
fileNotFoundMsg: "File '{fileName}' not found for download.",
|
||||
confirmLeaveWhileTransferring: "Transfer will be interrupted. Can be resumed if save directory is set. Exit anyway?",
|
||||
leaveWhileTransferringSuccess: "Left room, transfer interrupted",
|
||||
html: {
|
||||
senderTab: "Send",
|
||||
retrieveTab: "Retrieve",
|
||||
@@ -313,7 +336,8 @@ export const en: Messages = {
|
||||
Copy_dis: "Copy",
|
||||
inputRoomIdprompt: "Your RoomID (Editable):",
|
||||
joinRoomBtn: "Join room",
|
||||
generateRoomId_tips: "Random ID",
|
||||
generateSimpleId_tips: "Simple ID",
|
||||
generateRandomId_tips: "Random ID",
|
||||
readClipboardToRoomId: "Paste RoomID",
|
||||
enterRoomID_placeholder: "enter RoomID",
|
||||
retrieveMethod: "Retrieve method",
|
||||
@@ -324,7 +348,14 @@ export const en: Messages = {
|
||||
readClipboard_dis: "Paste RoomID",
|
||||
retrieveRoomId_placeholder: "Enter RoomID",
|
||||
RetrieveMethodTitle: "Retrieve method",
|
||||
// New: cached ID utils
|
||||
saveId_dis: "Save ID",
|
||||
useCachedId_dis: "Use cached ID",
|
||||
saveId_tips: "Save current ID for quick reuse later",
|
||||
useCachedId_tips: "Quick use saved ID; double-click to switch save mode",
|
||||
},
|
||||
// New: cache messages
|
||||
saveId_success: "Saved to cache",
|
||||
},
|
||||
home: {
|
||||
h1: "Free Secure Online Clipboard & File Transfer Tool",
|
||||
|
||||
@@ -45,6 +45,14 @@ export const es: Messages = {
|
||||
description:
|
||||
"Revise los términos de uso de PrivyDrop, incluyendo información sobre el uso aceptable del servicio, privacidad y seguridad de datos, y limitaciones de responsabilidad.",
|
||||
},
|
||||
blog: {
|
||||
title:
|
||||
"Blog de PrivyDrop - Compartición de archivos P2P privada y colaboración",
|
||||
description:
|
||||
"Descubre consejos de compartición segura de archivos, estrategias de colaboración con enfoque en la privacidad y cómo aprovechar P2P y WebRTC para transferencias más seguras.",
|
||||
keywords:
|
||||
"compartición segura de archivos,transferencia de archivos p2p,colaboración privada,webrtc,cifrado de extremo a extremo,colaboración en equipo,herramientas de privacidad",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header: {
|
||||
@@ -63,6 +71,19 @@ export const es: Messages = {
|
||||
Privacy_dis: "Política de Privacidad",
|
||||
SupportedLanguages: "Idiomas soportados",
|
||||
},
|
||||
blog: {
|
||||
list_title: "Blog",
|
||||
list_subtitle: "Últimos artículos y actualizaciones",
|
||||
recent_posts: "Entradas recientes",
|
||||
tags: "Etiquetas",
|
||||
read_more: "Leer más",
|
||||
by: "por",
|
||||
post_not_found: "Artículo no encontrado",
|
||||
toc_title: "Tabla de contenidos",
|
||||
tag_title_prefix: "Etiqueta",
|
||||
tag_subtitle_template: "Artículos etiquetados con {tag}",
|
||||
tag_empty: "No se encontraron artículos para esta etiqueta.",
|
||||
},
|
||||
privacy: {
|
||||
PrivacyPolicy_dis: "Política de Privacidad",
|
||||
h1: "Política de Privacidad de PrivyDrop",
|
||||
@@ -233,9 +254,11 @@ export const es: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Por favor espera a que se complete la transferencia actual",
|
||||
download_tips: "Haz clic para descargar el archivo",
|
||||
PendingSave_tips: "Haz clic para guardar el archivo localmente", // 新增
|
||||
Saved_dis: "Guardado",
|
||||
Waiting_dis: "Esperando",
|
||||
Download_dis: "Descargar",
|
||||
Save_dis: "Guardar", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Enviando",
|
||||
@@ -252,11 +275,6 @@ export const es: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Guarda archivos grandes o carpetas directamente en un directorio seleccionado. 👉",
|
||||
chooseSavePath_dis: "Elegir ubicación de guardado",
|
||||
safeSave_dis: "Guardar Seguro",
|
||||
safeSave_tooltip:
|
||||
"No te preocupes por la interrupción de la conexión, haz clic aquí para guardar archivos de forma segura para la próxima reanudación",
|
||||
safeSaveSuccessMsg:
|
||||
"Los archivos se han guardado de forma segura en el disco, es seguro cerrar la página, ¡admite transferencia de reanudación!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "¡Felicitaciones 🎉 El contenido compartido está esperando ser recuperado:",
|
||||
@@ -281,8 +299,7 @@ export const es: Messages = {
|
||||
"Esperando que el receptor se conecte. Por favor mantén esta página abierta hasta que se complete la transferencia. En escritorio, puedes minimizar el navegador o cambiar pestañas. En móvil, por favor mantén el navegador en primer plano.",
|
||||
joinRoom: {
|
||||
EmptyMsg: "Advertencia, el ID de sala está vacío",
|
||||
DuplicateMsg:
|
||||
"El ID de sala que ingresaste está duplicado. Por favor, vuelve a ingresar.",
|
||||
DuplicateMsg: "Este ID de sala ya está en uso. Por favor, elige otro ID.",
|
||||
successMsg:
|
||||
"¡Ingreso exitoso al cuarto! No cierres esta página hasta que se complete la transferencia. (En escritorio, puedes minimizar el navegador o cambiar de pestaña; en móvil, no lleves el navegador al fondo.)",
|
||||
notExist:
|
||||
@@ -290,6 +307,9 @@ export const es: Messages = {
|
||||
failMsg: "Error al unirse a la sala:",
|
||||
},
|
||||
pickSaveMsg: "¿Guardar Directamente en Disco?",
|
||||
pickSaveUnsupported: "Selector de directorio no compatible.",
|
||||
pickSaveSuccess: "Ubicación de guardado establecida.",
|
||||
pickSaveError: "No se pudo establecer la ubicación de guardado.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "La sala está vacía",
|
||||
receiverEmptyMsg: "Puedes aceptar una invitación para unirte a la sala",
|
||||
@@ -305,6 +325,10 @@ export const es: Messages = {
|
||||
"No se encontraron archivos en la carpeta '{folderName}'.",
|
||||
zipError: "Error al crear el archivo ZIP.",
|
||||
fileNotFoundMsg: "Archivo '{fileName}' no encontrado para descargar.",
|
||||
confirmLeaveWhileTransferring:
|
||||
"Transferencia se interrumpirá. Se puede reanudar si hay directorio de guardado. ¿Salir de todos modos?",
|
||||
leaveWhileTransferringSuccess:
|
||||
"Saliste de la sala, transferencia interrumpida",
|
||||
html: {
|
||||
senderTab: "Enviar",
|
||||
retrieveTab: "Recuperar",
|
||||
@@ -315,7 +339,8 @@ export const es: Messages = {
|
||||
Copy_dis: "Copiar",
|
||||
inputRoomIdprompt: "Tu ID de Sala (Editable):",
|
||||
joinRoomBtn: "Unirse a sala",
|
||||
generateRoomId_tips: "ID Aleatorio",
|
||||
generateSimpleId_tips: "ID Simple",
|
||||
generateRandomId_tips: "ID Aleatorio",
|
||||
readClipboardToRoomId: "Pegar ID de Sala",
|
||||
enterRoomID_placeholder: "ingresa ID de Sala",
|
||||
retrieveMethod: "Método de recuperación",
|
||||
@@ -326,7 +351,14 @@ export const es: Messages = {
|
||||
readClipboard_dis: "Pegar ID de Sala",
|
||||
retrieveRoomId_placeholder: "Ingresa ID de Sala",
|
||||
RetrieveMethodTitle: "Método de recuperación",
|
||||
// New: cached ID utils
|
||||
saveId_dis: "Guardar ID",
|
||||
useCachedId_dis: "Usar ID en caché",
|
||||
saveId_tips: "Guarda el ID actual para reutilizarlo rápidamente",
|
||||
useCachedId_tips: "Usar ID guardado rápido; doble clic para cambiar modo guardar",
|
||||
},
|
||||
// New: cache messages
|
||||
saveId_success: "Guardado en caché",
|
||||
},
|
||||
home: {
|
||||
h1: "Herramienta Gratuita de Portapapeles y Transferencia de Archivos en Línea Segura",
|
||||
|
||||
@@ -45,6 +45,14 @@ export const fr: Messages = {
|
||||
description:
|
||||
"Consultez les conditions d'utilisation de PrivyDrop, y compris des informations sur l'utilisation acceptable du service, la confidentialité et la sécurité des données, ainsi que les limitations de responsabilité.",
|
||||
},
|
||||
blog: {
|
||||
title:
|
||||
"Blog PrivyDrop - Partage de fichiers P2P privé et collaboration",
|
||||
description:
|
||||
"Découvrez des conseils pour un partage de fichiers sécurisé, des stratégies de collaboration axées sur la confidentialité et comment tirer parti de P2P et WebRTC pour des transferts plus sûrs.",
|
||||
keywords:
|
||||
"partage de fichiers sécurisé,transfert de fichiers p2p,collaboration privée,webrtc,chiffrement de bout en bout,collaboration d'équipe,outils de confidentialité",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header: {
|
||||
@@ -63,6 +71,19 @@ export const fr: Messages = {
|
||||
Privacy_dis: "Politique de confidentialité",
|
||||
SupportedLanguages: "Langues prises en charge",
|
||||
},
|
||||
blog: {
|
||||
list_title: "Blog",
|
||||
list_subtitle: "Derniers articles et mises à jour",
|
||||
recent_posts: "Articles récents",
|
||||
tags: "Étiquettes",
|
||||
read_more: "En savoir plus",
|
||||
by: "par",
|
||||
post_not_found: "Article introuvable",
|
||||
toc_title: "Table des matières",
|
||||
tag_title_prefix: "Étiquette",
|
||||
tag_subtitle_template: "Articles marqués avec {tag}",
|
||||
tag_empty: "Aucun article trouvé pour cette étiquette.",
|
||||
},
|
||||
|
||||
privacy: {
|
||||
PrivacyPolicy_dis: "Politique de confidentialité",
|
||||
@@ -236,9 +257,11 @@ export const fr: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Veuillez attendre que le transfert actuel soit terminé",
|
||||
download_tips: "Cliquez pour télécharger le fichier",
|
||||
PendingSave_tips: "Cliquez pour enregistrer le fichier localement", // 新增
|
||||
Saved_dis: "Enregistré",
|
||||
Waiting_dis: "En attente",
|
||||
Download_dis: "Télécharger",
|
||||
Save_dis: "Enregistrer", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Envoi",
|
||||
@@ -255,11 +278,6 @@ export const fr: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Enregistrez des fichiers volumineux ou des dossiers directement dans un répertoire sélectionné. 👉",
|
||||
chooseSavePath_dis: "Choisir l'emplacement de sauvegarde",
|
||||
safeSave_dis: "Sauvegarde Sécurisée",
|
||||
safeSave_tooltip:
|
||||
"N'ayez pas peur de l'interruption de connexion, cliquez ici pour sauvegarder les fichiers en toute sécurité pour la prochaine reprise",
|
||||
safeSaveSuccessMsg:
|
||||
"Les fichiers ont été sauvegardés en toute sécurité sur le disque, sûr de fermer la page, prend en charge la reprise du transfert !",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "Félicitations 🎉 Le contenu partagé attend d'être récupéré :",
|
||||
@@ -287,8 +305,7 @@ export const fr: Messages = {
|
||||
"En attente de la connexion du destinataire. Veuillez garder cette page ouverte jusqu'à la fin du transfert. Sur ordinateur, vous pouvez minimiser le navigateur ou changer d'onglet. Sur mobile, veuillez garder le navigateur au premier plan.",
|
||||
joinRoom: {
|
||||
EmptyMsg: "Avertissement, l'ID de salle est vide",
|
||||
DuplicateMsg:
|
||||
"L'ID de salle que vous avez entré est en double. Veuillez le réentrer.",
|
||||
DuplicateMsg: "Cet ID de salle est déjà utilisé. Veuillez choisir un autre ID.",
|
||||
successMsg:
|
||||
"Rejoignez le salon avec succès ! Ne fermez pas cette page tant que le transfert n'est pas terminé. (Sur ordinateur, vous pouvez réduire le navigateur ou changer d'onglet ; sur mobile, ne mettez pas le navigateur en arrière-plan.)",
|
||||
notExist:
|
||||
@@ -296,6 +313,9 @@ export const fr: Messages = {
|
||||
failMsg: "Échec de la connexion à la salle :",
|
||||
},
|
||||
pickSaveMsg: "Enregistrer directement sur le disque ?",
|
||||
pickSaveUnsupported: "Sélecteur de répertoire non pris en charge.",
|
||||
pickSaveSuccess: "Emplacement de sauvegarde défini.",
|
||||
pickSaveError: "Impossible de définir l'emplacement de sauvegarde.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "La salle est vide",
|
||||
receiverEmptyMsg:
|
||||
@@ -313,6 +333,9 @@ export const fr: Messages = {
|
||||
zipError: "Erreur lors de la création du fichier ZIP.",
|
||||
fileNotFoundMsg:
|
||||
"Fichier '{fileName}' introuvable pour le téléchargement.",
|
||||
confirmLeaveWhileTransferring:
|
||||
"Le transfert sera interrompu. Reprenez si un répertoire de sauvegarde est défini. Quitter quand même ?",
|
||||
leaveWhileTransferringSuccess: "Salle quittée, transfert interrompu",
|
||||
html: {
|
||||
senderTab: "Envoyer",
|
||||
retrieveTab: "Récupérer",
|
||||
@@ -323,7 +346,8 @@ export const fr: Messages = {
|
||||
Copy_dis: "Copier",
|
||||
inputRoomIdprompt: "Votre ID de salle (modifiable) :",
|
||||
joinRoomBtn: "Rejoindre la salle",
|
||||
generateRoomId_tips: "ID Aléatoire",
|
||||
generateSimpleId_tips: "ID Simple",
|
||||
generateRandomId_tips: "ID Aléatoire",
|
||||
readClipboardToRoomId: "Coller l'ID de salle",
|
||||
enterRoomID_placeholder: "entrez l'ID de salle",
|
||||
retrieveMethod: "Méthode de récupération",
|
||||
@@ -334,7 +358,15 @@ export const fr: Messages = {
|
||||
readClipboard_dis: "Coller l'ID de salle",
|
||||
retrieveRoomId_placeholder: "Entrez l'ID de salle",
|
||||
RetrieveMethodTitle: "Méthode de récupération",
|
||||
// New: cached ID utils
|
||||
saveId_dis: "Enregistrer l’ID",
|
||||
useCachedId_dis: "Utiliser l’ID en cache",
|
||||
saveId_tips:
|
||||
"Enregistrez l’ID actuel pour une réutilisation rapide",
|
||||
useCachedId_tips: "Utiliser ID enregistré rapide; double-clic pour changer mode sauvegarde",
|
||||
},
|
||||
// New: cache messages
|
||||
saveId_success: "Enregistré dans le cache",
|
||||
},
|
||||
home: {
|
||||
h1: "Outil gratuit de transfert de fichiers et de presse-papiers en ligne sécurisé",
|
||||
|
||||
@@ -45,6 +45,14 @@ export const ja: Messages = {
|
||||
description:
|
||||
"PrivyDropの利用規約を確認しましょう。サービスの適切な使用、データプライバシーとセキュリティ、責任の制限に関する情報が含まれます。",
|
||||
},
|
||||
blog: {
|
||||
title:
|
||||
"PrivyDrop ブログ - プライベートなP2Pファイル共有とコラボレーション",
|
||||
description:
|
||||
"安全なファイル共有のヒント、プライバシー重視のコラボレーション戦略、そしてP2PとWebRTCを活用したより安全なデータ転送について学びましょう。",
|
||||
keywords:
|
||||
"安全なファイル共有,p2pファイル転送,プライベートコラボレーション,webrtc,エンドツーエンド暗号化,チームコラボレーション,プライバシーツール",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header: {
|
||||
@@ -63,6 +71,19 @@ export const ja: Messages = {
|
||||
Privacy_dis: "プライバシーポリシー",
|
||||
SupportedLanguages: "対応言語",
|
||||
},
|
||||
blog: {
|
||||
list_title: "ブログ",
|
||||
list_subtitle: "最新の記事と更新",
|
||||
recent_posts: "最新の投稿",
|
||||
tags: "タグ",
|
||||
read_more: "続きを読む",
|
||||
by: "著者",
|
||||
post_not_found: "記事が見つかりません",
|
||||
toc_title: "目次",
|
||||
tag_title_prefix: "タグ",
|
||||
tag_subtitle_template: "「{tag}」のタグが付いた記事",
|
||||
tag_empty: "このタグの記事は見つかりません。",
|
||||
},
|
||||
privacy: {
|
||||
PrivacyPolicy_dis: "プライバシーポリシー",
|
||||
h1: "PrivyDropプライバシーポリシー",
|
||||
@@ -229,9 +250,11 @@ export const ja: Messages = {
|
||||
CurrentFileTransferring_tips: "ファイルが転送中です",
|
||||
OtherFileTransferring_tips: "現在の転送が完了するまでお待ちください",
|
||||
download_tips: "クリックしてファイルをダウンロード",
|
||||
PendingSave_tips: "クリックしてファイルをローカルに保存", // 新增
|
||||
Saved_dis: "保存済み",
|
||||
Waiting_dis: "待機中",
|
||||
Download_dis: "ダウンロード",
|
||||
Save_dis: "保存", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "送信中",
|
||||
@@ -247,9 +270,6 @@ export const ja: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"大きなファイルやフォルダを選択したディレクトリに直接保存します。👉",
|
||||
chooseSavePath_dis: "保存場所を選択",
|
||||
safeSave_dis: "安全保存",
|
||||
safeSave_tooltip: "接続の中断を恐れる必要はありません。ここをクリックして、次回の再開のためにファイルを安全に保存してください",
|
||||
safeSaveSuccessMsg: "ファイルが安全にディスクに保存されました。ページを安全に閉じることができ、転送の再開をサポートします!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "おめでとう 🎉 共有コンテンツが取得待ちです:",
|
||||
@@ -275,7 +295,7 @@ export const ja: Messages = {
|
||||
"受信者が接続するのを待っています。転送が完了するまでこのページを開いたままにしてください。デスクトップでは、ブラウザを最小化したり、タブを切り替えたりできます。モバイルでは、ブラウザをフォアグラウンドに保ってください。",
|
||||
joinRoom: {
|
||||
EmptyMsg: "警告、ルームIDが空です",
|
||||
DuplicateMsg: "入力したルームIDが重複しています。再入力してください。",
|
||||
DuplicateMsg: "このルームIDは既に使用されています。別のIDをご利用ください。",
|
||||
successMsg:
|
||||
"ルームに成功して参加しました!転送が完了するまでこのページを閉じないでください。(PCではブラウザを最小化したりタブを切り替えたりできます。モバイルではブラウザをバックグラウンドにしないでください。)",
|
||||
notExist:
|
||||
@@ -283,6 +303,9 @@ export const ja: Messages = {
|
||||
failMsg: "ルームへの参加に失敗しました:",
|
||||
},
|
||||
pickSaveMsg: "ディスクに直接保存しますか?",
|
||||
pickSaveUnsupported: "ディレクトリピッカーはサポートされていません。",
|
||||
pickSaveSuccess: "保存場所が設定されました。",
|
||||
pickSaveError: "保存場所を設定できませんでした。",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "ルームは空です",
|
||||
receiverEmptyMsg: "招待を受けてルームに参加できます",
|
||||
@@ -297,6 +320,8 @@ export const ja: Messages = {
|
||||
noFilesForFolderMsg: "フォルダ '{folderName}' にファイルが見つかりません。",
|
||||
zipError: "ZIP の作成中にエラーが発生しました。",
|
||||
fileNotFoundMsg: "ダウンロードするファイル '{fileName}' が見つかりません。",
|
||||
confirmLeaveWhileTransferring: "転送が中断されます。保存先設定時は再開可能。退出しますか?",
|
||||
leaveWhileTransferringSuccess: "ルームを退出しました。転送が中断されました",
|
||||
html: {
|
||||
senderTab: "送信",
|
||||
retrieveTab: "取得",
|
||||
@@ -307,7 +332,8 @@ export const ja: Messages = {
|
||||
Copy_dis: "コピー",
|
||||
inputRoomIdprompt: "ルームID(編集可能):",
|
||||
joinRoomBtn: "ルームに参加",
|
||||
generateRoomId_tips: "ランダムID",
|
||||
generateSimpleId_tips: "シンプルID",
|
||||
generateRandomId_tips: "ランダムID",
|
||||
readClipboardToRoomId: "ルームIDを貼り付け",
|
||||
enterRoomID_placeholder: "ルームIDを入力",
|
||||
retrieveMethod: "取得方法",
|
||||
@@ -318,7 +344,14 @@ export const ja: Messages = {
|
||||
readClipboard_dis: "ルームIDを貼り付け",
|
||||
retrieveRoomId_placeholder: "ルームIDを入力",
|
||||
RetrieveMethodTitle: "取得方法",
|
||||
// New: cached ID utils
|
||||
saveId_dis: "ID を保存",
|
||||
useCachedId_dis: "保存済みIDを使用",
|
||||
saveId_tips: "現在のIDを保存して次回すぐに使えるようにします",
|
||||
useCachedId_tips: "保存済みIDを即使用;ダブルクリックで保存モード切替",
|
||||
},
|
||||
// New: cache messages
|
||||
saveId_success: "キャッシュに保存しました",
|
||||
},
|
||||
home: {
|
||||
h1: "無料で安全なオンラインクリップボード&ファイル転送ツール",
|
||||
|
||||
@@ -45,6 +45,14 @@ export const ko: Messages = {
|
||||
description:
|
||||
"PrivyDrop의 이용 약관을 검토하세요. 서비스의 허용 가능한 사용, 데이터 개인 정보 보호 및 보안, 책임 제한에 대한 정보를 포함합니다.",
|
||||
},
|
||||
blog: {
|
||||
title:
|
||||
"PrivyDrop 블로그 - 개인 P2P 파일 공유 및 협업",
|
||||
description:
|
||||
"안전한 파일 공유 팁, 개인 정보 중심의 협업 전략, 그리고 P2P와 WebRTC를 활용한 더 안전한 데이터 전송 방법을 알아보세요.",
|
||||
keywords:
|
||||
"안전한 파일 공유,p2p 파일 전송,개인 협업,webrtc,종단간 암호화,팀 협업,프라이버시 도구",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header: {
|
||||
@@ -63,6 +71,19 @@ export const ko: Messages = {
|
||||
Privacy_dis: "개인정보 보호정책",
|
||||
SupportedLanguages: "지원 언어",
|
||||
},
|
||||
blog: {
|
||||
list_title: "블로그",
|
||||
list_subtitle: "최신 글과 업데이트",
|
||||
recent_posts: "최근 글",
|
||||
tags: "태그",
|
||||
read_more: "더 보기",
|
||||
by: "작성자",
|
||||
post_not_found: "게시글을 찾을 수 없습니다",
|
||||
toc_title: "목차",
|
||||
tag_title_prefix: "태그",
|
||||
tag_subtitle_template: "{tag} 태그가 달린 글",
|
||||
tag_empty: "해당 태그의 글이 없습니다.",
|
||||
},
|
||||
|
||||
privacy: {
|
||||
PrivacyPolicy_dis: "개인정보 보호정책",
|
||||
@@ -227,9 +248,11 @@ export const ko: Messages = {
|
||||
CurrentFileTransferring_tips: "파일 전송 중",
|
||||
OtherFileTransferring_tips: "현재 전송이 완료될 때까지 기다려주세요",
|
||||
download_tips: "파일을 다운로드하려면 클릭하세요",
|
||||
PendingSave_tips: "로컬에 파일을 저장하려면 클릭하세요", // 新增
|
||||
Saved_dis: "저장됨",
|
||||
Waiting_dis: "대기 중",
|
||||
Download_dis: "다운로드",
|
||||
Save_dis: "저장", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "전송 중",
|
||||
@@ -245,9 +268,6 @@ export const ko: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"큰 파일이나 폴더를 선택한 디렉터리에 직접 저장합니다. 👉",
|
||||
chooseSavePath_dis: "저장 위치 선택",
|
||||
safeSave_dis: "안전 저장",
|
||||
safeSave_tooltip: "연결 중단을 두려워하지 마세요. 다음 재개를 위해 파일을 안전하게 저장하려면 여기를 클릭하세요",
|
||||
safeSaveSuccessMsg: "파일이 디스크에 안전하게 저장되었습니다. 페이지를 안전하게 닫을 수 있으며 전송 재개를 지원합니다!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "축하합니다 🎉 공유된 콘텐츠가 검색을 기다리고 있습니다:",
|
||||
@@ -273,7 +293,7 @@ export const ko: Messages = {
|
||||
"수신자가 연결될 때까지 기다리는 중입니다. 전송이 완료될 때까지 이 페이지를 열어 두세요. 데스크톱에서는 브라우저를 최소화하거나 탭을 전환할 수 있습니다. 모바일에서는 브라우저를 포그라운드에 유지하세요.",
|
||||
joinRoom: {
|
||||
EmptyMsg: "경고, 방 ID가 비어 있습니다",
|
||||
DuplicateMsg: "입력한 방 ID가 중복되었습니다. 다시 입력해주세요.",
|
||||
DuplicateMsg: "이 방 ID는 이미 사용 중입니다. 다른 ID를 선택해주세요.",
|
||||
successMsg:
|
||||
"방에 성공적으로 입장했습니다! 전송이 완료되기 전까지 현재 페이지를 닫지 마세요. (데스크톱에서는 브라우저를 최소화하거나 탭을 전환할 수 있으며, 모바일에서는 브라우저를 백그라운드로 이동하지 마세요.)",
|
||||
notExist:
|
||||
@@ -281,6 +301,9 @@ export const ko: Messages = {
|
||||
failMsg: "방 참여 실패:",
|
||||
},
|
||||
pickSaveMsg: "직접 디스크에 저장하시겠습니까?",
|
||||
pickSaveUnsupported: "디렉토리 선택기가 지원되지 않습니다.",
|
||||
pickSaveSuccess: "저장 위치가 설정되었습니다.",
|
||||
pickSaveError: "저장 위치를 설정할 수 없습니다.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "방이 비어 있습니다",
|
||||
receiverEmptyMsg: "초대를 수락하여 방에 참여할 수 있습니다",
|
||||
@@ -295,6 +318,8 @@ export const ko: Messages = {
|
||||
noFilesForFolderMsg: "폴더 '{folderName}'에서 파일을 찾을 수 없습니다.",
|
||||
zipError: "ZIP 파일 생성 중 오류가 발생했습니다.",
|
||||
fileNotFoundMsg: "다운로드할 파일 '{fileName}'을(를) 찾을 수 없습니다.",
|
||||
confirmLeaveWhileTransferring: "전송이 중단됩니다. 저장 경로 설정 시 재개 가능. 나가시겠습니까?",
|
||||
leaveWhileTransferringSuccess: "방을 나갔습니다. 전송이 중단되었습니다",
|
||||
html: {
|
||||
senderTab: "보내기",
|
||||
retrieveTab: "검색",
|
||||
@@ -305,7 +330,8 @@ export const ko: Messages = {
|
||||
Copy_dis: "복사",
|
||||
inputRoomIdprompt: "방 ID (편집 가능):",
|
||||
joinRoomBtn: "방 참여",
|
||||
generateRoomId_tips: "랜덤 ID",
|
||||
generateSimpleId_tips: "간단 ID",
|
||||
generateRandomId_tips: "랜덤 ID",
|
||||
readClipboardToRoomId: "방 ID 붙여넣기",
|
||||
enterRoomID_placeholder: "방 ID 입력",
|
||||
retrieveMethod: "검색 방법",
|
||||
@@ -316,7 +342,15 @@ export const ko: Messages = {
|
||||
readClipboard_dis: "방 ID 붙여넣기",
|
||||
retrieveRoomId_placeholder: "방 ID 입력",
|
||||
RetrieveMethodTitle: "검색 방법",
|
||||
// New: cached ID utils
|
||||
saveId_dis: "ID 저장",
|
||||
useCachedId_dis: "저장된 ID 사용",
|
||||
saveId_tips:
|
||||
"현재 ID를 저장하여 다음에 빠르게 사용할 수 있어요",
|
||||
useCachedId_tips: "저장된 ID 빠르게 사용;더블클릭으로 저장 모드 전환",
|
||||
},
|
||||
// New: cache messages
|
||||
saveId_success: "캐시에 저장되었습니다",
|
||||
},
|
||||
home: {
|
||||
h1: "무료 보안 온라인 클립보드 및 파일 전송 도구",
|
||||
|
||||
@@ -42,6 +42,13 @@ export const zh: Messages = {
|
||||
description:
|
||||
"查看PrivyDrop使用条款,包括服务使用规范、数据隐私和安全性,以及责任限制等信息。",
|
||||
},
|
||||
blog: {
|
||||
title: "PrivyDrop 博客 - 私密 P2P 文件分享与协作",
|
||||
description:
|
||||
"探索安全的文件分享方法、隐私优先的团队协作策略,以及如何利用 P2P 与 WebRTC 实现更安全的数据传输。",
|
||||
keywords:
|
||||
"安全文件分享,P2P文件传输,私密协作,WebRTC,端到端加密,团队协作,隐私工具",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header: {
|
||||
@@ -60,6 +67,19 @@ export const zh: Messages = {
|
||||
Privacy_dis: "隐私政策",
|
||||
SupportedLanguages: "支持的语言",
|
||||
},
|
||||
blog: {
|
||||
list_title: "博客",
|
||||
list_subtitle: "最新文章与更新",
|
||||
recent_posts: "最新文章",
|
||||
tags: "标签",
|
||||
read_more: "阅读更多",
|
||||
by: "作者",
|
||||
post_not_found: "未找到文章",
|
||||
toc_title: "目录",
|
||||
tag_title_prefix: "标签",
|
||||
tag_subtitle_template: "包含 {tag} 标签的文章",
|
||||
tag_empty: "没有找到相关文章。",
|
||||
},
|
||||
privacy: {
|
||||
PrivacyPolicy_dis: "隐私政策",
|
||||
h1: "PrivyDrop隐私政策",
|
||||
@@ -216,9 +236,11 @@ export const zh: Messages = {
|
||||
CurrentFileTransferring_tips: "文件正在传输中",
|
||||
OtherFileTransferring_tips: "请等待当前传输完成",
|
||||
download_tips: "点击下载文件",
|
||||
PendingSave_tips: "点击保存文件到本地", // 新增
|
||||
Saved_dis: "已保存",
|
||||
Waiting_dis: "等待中",
|
||||
Download_dis: "下载",
|
||||
Save_dis: "保存", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "发送中",
|
||||
@@ -233,10 +255,6 @@ export const zh: Messages = {
|
||||
"我们建议选择一个保存目录来直接将文件保存到磁盘。这样可以更方便地传输大文件和同步文件夹。",
|
||||
chooseSavePath_tips: "大文件或文件夹可直接保存到指定目录 👉",
|
||||
chooseSavePath_dis: "选择保存位置",
|
||||
safeSave_dis: "安全保存",
|
||||
safeSave_tooltip: "连接中断不要怕,点击这里安全保存文件,方便下次续传",
|
||||
safeSaveSuccessMsg:
|
||||
"文件已安全保存到磁盘,可以安全关闭页面,支持断点续传!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "恭喜 🎉 共享内容等待接收:",
|
||||
@@ -261,13 +279,16 @@ export const zh: Messages = {
|
||||
"等待接收方连接。请保持此页面打开直到传输完成。在桌面端,您可以最小化浏览器或切换标签页。在移动端,请保持浏览器在前台。",
|
||||
joinRoom: {
|
||||
EmptyMsg: "警告,房间ID为空",
|
||||
DuplicateMsg: "您输入的房间ID重复,请重新输入。",
|
||||
DuplicateMsg: "该房间ID已被使用,请更换其他ID。",
|
||||
successMsg:
|
||||
"成功加入房间!在被接收之前不要关闭当前页(电脑端可以最小化浏览器或切换tab页,移动端不要将浏览器切到后台)。",
|
||||
notExist: "您尝试加入的房间不存在。只有发送方可以创建房间。",
|
||||
failMsg: "加入房间失败:",
|
||||
},
|
||||
pickSaveMsg: "直接保存到磁盘?",
|
||||
pickSaveUnsupported: "不支持目录选择器。",
|
||||
pickSaveSuccess: "保存位置已设置。",
|
||||
pickSaveError: "无法设置保存位置。",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "房间为空",
|
||||
receiverEmptyMsg: "您可以接受邀请加入房间",
|
||||
@@ -282,6 +303,9 @@ export const zh: Messages = {
|
||||
noFilesForFolderMsg: "在文件夹 '{folderName}' 中未找到文件。",
|
||||
zipError: "创建 ZIP 文件时出错。",
|
||||
fileNotFoundMsg: "未找到要下载的文件 '{fileName}'。",
|
||||
confirmLeaveWhileTransferring:
|
||||
"传输将中断,已设置保存目录时可续传。确定退出?",
|
||||
leaveWhileTransferringSuccess: "已退出房间,传输已中断",
|
||||
html: {
|
||||
senderTab: "发送",
|
||||
retrieveTab: "接收",
|
||||
@@ -292,7 +316,8 @@ export const zh: Messages = {
|
||||
Copy_dis: "复制",
|
||||
inputRoomIdprompt: "您的房间ID(可编辑):",
|
||||
joinRoomBtn: "加入房间",
|
||||
generateRoomId_tips: "随机ID",
|
||||
generateSimpleId_tips: "简单ID",
|
||||
generateRandomId_tips: "随机ID",
|
||||
readClipboardToRoomId: "粘贴房间ID",
|
||||
enterRoomID_placeholder: "输入房间ID",
|
||||
retrieveMethod: "接收方式",
|
||||
@@ -303,7 +328,14 @@ export const zh: Messages = {
|
||||
readClipboard_dis: "粘贴房间ID",
|
||||
retrieveRoomId_placeholder: "输入房间ID",
|
||||
RetrieveMethodTitle: "接收方式",
|
||||
// New: cached ID utils
|
||||
saveId_dis: "保存ID",
|
||||
useCachedId_dis: "使用缓存ID",
|
||||
saveId_tips: "保存ID后,下次可以快捷使用该ID",
|
||||
useCachedId_tips: "快捷使用已保存ID;双击可切换保存模式",
|
||||
},
|
||||
// New: cache messages
|
||||
saveId_success: "缓存成功",
|
||||
},
|
||||
home: {
|
||||
h1: "免费安全的在线剪贴板与文件传输工具",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "Ein Klick, wieder da – Fels in der Brandung: Cached‑ID‑Auto‑Join und robuste Wiederverbindung in PrivyDrop"
|
||||
description: "Neu auf der Empfängerseite: Auto‑Beitritt per zwischengespeicherter ID und durchgängige Wiederverbindung für geschmeidige Abläufe – automatischer Raumbeitritt, One‑Click‑Connect, Doppelklick zum Cache‑Update und stabile Erholung in wackeligen Netzen."
|
||||
date: "2025-11-25"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/cached-id-reconnect.webp"
|
||||
tags: ["Neues Feature", "Automatische Wiederverbindung", "Gecachte ID", "WebRTC", "P2P"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Einleitung: Warum „Auto‑Join“ und „Wiederverbindung“ zählen
|
||||
|
||||
Neue PrivyDrop‑Nutzer stolpern häufig über zwei kleine Reibungen:
|
||||
|
||||
- Beim Wechsel von Senden zu Empfangen muss die Raum‑ID erneut eingefügt werden.
|
||||
- In Café‑WLANs oder im Mobilfunk erzwingt ein kurzer Haker eine manuelle Wiederverbindung.
|
||||
|
||||
Klein – und doch im Alltag entscheidend dafür, ob sich etwas „mühelos“ anfühlt. Deshalb haben wir zwei Feinschliffe ausgeliefert, die den Fluss wirklich glattziehen:
|
||||
|
||||
- „Cached‑ID‑Auto‑Join“ für Empfänger: Wenn die Bedingungen passen, füllen wir automatisch aus und treten sofort bei.
|
||||
- Durchgängige, robuste Wiederverbindung: Fällt Socket oder P2P, erholen sich Aushandlung und Verbindung selbsttätig.
|
||||
|
||||
Das alles ohne unsere Architektur‑Leitplanke zu verletzen: Backend nur für Signalisierung und Räume; Dateien bleiben E2E‑verschlüsselt und gehen Browser‑zu‑Browser direkt.
|
||||
|
||||
---
|
||||
|
||||
## Funktion 1: Auto‑Join mit zwischengespeicherter ID (Empfänger)
|
||||
|
||||
Beim Wechsel zum Empfangen füllen wir die letzte gespeicherte Raum‑ID automatisch ein und treten sofort bei, wenn:
|
||||
|
||||
- Sie sich im Empfangen‑Tab befinden und noch keinem Raum beigetreten sind;
|
||||
- die URL keinen `roomId`‑Parameter enthält (URL hat Vorrang – kein Überschreiben);
|
||||
- das Eingabefeld leer ist (kein Überschreiben der Nutzer‑Eingabe);
|
||||
- eine gecachte ID in localStorage vorhanden ist.
|
||||
|
||||
Der Check läuft beim Tab‑Wechsel: Zuerst wird ausgefüllt, dann direkt die Beitrittslogik aufgerufen – ein Einfügen/Klick weniger.
|
||||
|
||||
- Code‑Anker:
|
||||
- Auto‑Join useEffect (Empfänger): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
|
||||
- Cache‑Helfer (localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
|
||||
|
||||
Wann greift es nicht?
|
||||
|
||||
- Sie sind bereits in einem Raum;
|
||||
- die URL trägt explizit `roomId` (z. B. geteilter Deep‑Link);
|
||||
- das Eingabefeld enthält bereits Text in Bearbeitung;
|
||||
- keine gecachte ID gefunden.
|
||||
|
||||
---
|
||||
|
||||
## Funktion 2: „ID speichern/verwenden“ auf Sender‑Seite (Doppelklick zum Aktualisieren)
|
||||
|
||||
Auf der Sender‑Seite bekommt das ID‑Feld einen smarten „Wiederverwenden“‑Button mit zwei Zuständen:
|
||||
|
||||
- ID speichern: Ab Eingabelänge ≥ 8 wird der Button aktiv; Klick speichert die aktuelle Eingabe als Cache‑ID.
|
||||
- Gecachte ID verwenden: Existiert eine, schreibt ein Klick sie ins Feld und tritt sofort bei; Doppelklick schaltet ~3 s auf „ID speichern“, um den Cache zu erneuern.
|
||||
|
||||
Implementierungsnotizen:
|
||||
|
||||
- Einfach/Doppelklick via 400 ms‑Fenster und Timer, Cleanup beim Unmount;
|
||||
- Nach „Gecachte ID verwenden“ tritt der Sender sofort dem Raum bei (kein zusätzlicher „Beitreten“‑Klick);
|
||||
- IDs mit weniger als 8 Zeichen werden nicht gespeichert – Schutz vor versehentlichen Kurz‑IDs.
|
||||
|
||||
- Code‑Anker:
|
||||
- Einfach/Doppelklick inkl. Timer‑Cleanup: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
|
||||
- Sofortiger Beitritt bei „Gecachte ID verwenden“ (Sender): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
|
||||
|
||||
---
|
||||
|
||||
## Wiederverbindung: Von der Erkennung bis zur Erholung
|
||||
|
||||
Wir beobachten drei Einstiegspunkte und stoßen die Wiederverbindung an:
|
||||
|
||||
- Socket getrennt: Nach Reconnect und geändertem `socketId` erfolgt der automatische Raum‑Beitritt;
|
||||
- P2P getrennt/fehlgeschlagen/geschlossen: Status markieren und Verbindung neu aufbauen;
|
||||
- Proaktiver `socketId`‑Check: Bei Socket‑Recovery erneut validieren.
|
||||
|
||||
- Code‑Anker:
|
||||
- Auto‑Beitritt nach Socket‑Connect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
|
||||
- Vereinheitlichter attemptReconnection‑Einstieg: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
|
||||
- `lastJoinedSocketId` verfolgen und bei Bedarf `initiator-online`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
|
||||
- Sender verarbeitet `recipient-ready` und startet Neuverhandlung: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
|
||||
- Empfänger antwortet auf `initiator-online` mit `recipient-ready`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
|
||||
- Backend‑Relais:
|
||||
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
|
||||
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
|
||||
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
|
||||
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
|
||||
|
||||
### Sequenz (Mermaid)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Signaling Server
|
||||
participant A as Sender (initiator)
|
||||
participant B as Empfänger (recipient)
|
||||
|
||||
Note over A,B: Netzschwankungen trennen Socket/P2P
|
||||
A->>A: attemptReconnection()
|
||||
A->>S: join(roomId) / (ggf.) initiator-online
|
||||
S-->>B: initiator-online
|
||||
B->>S: recipient-ready(peerId)
|
||||
S-->>A: recipient-ready(peerId)
|
||||
A->>B: offer
|
||||
B->>A: answer
|
||||
A-->>B: ICE candidates
|
||||
B-->>A: ICE candidates
|
||||
Note over A,B: Verbindung wiederhergestellt, DataChannel neu aufgebaut
|
||||
```
|
||||
|
||||
### Zuverlässigkeitsdetails
|
||||
|
||||
- ICE‑Kandidaten‑Queue: Ist die Remote‑Description nicht bereit oder die Verbindung im Schließen, werden Kandidaten gepuffert und später geflusht; siehe https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
|
||||
- DataChannel‑Backpressure & Chunking: Sender‑Schwelle `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); Netzsteuerung `maxBuffer≈3MB / lowThreshold≈512KB / 64KB‑Chunks` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
|
||||
- Mobile Wake Lock: Beim Verbinden anfordern, bei Trennung/Fehler freigeben – reduziert Unterbrechungen im Hintergrund.
|
||||
- Fehlerkapselung & Retries: seltene `sendData failed` werden gekapselt, angezeigt und erneut versucht (siehe `sendWithBackpressure`).
|
||||
|
||||
### Kurze vs. lange IDs: Wiederverwendungsstrategie
|
||||
|
||||
- Kurze IDs (4‑stellig) erhalten nach „leerem Raum + Trennung“ eine Gnaden‑TTL von 15 Min. (900 s) – schnelle Wiederverbindung im Fenster; siehe https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
|
||||
- Standard‑Ablaufzeit für Räume: 24 h; nur bei leerem Raum nach Trennung wird temporär auf 15 Min. umgestellt; siehe https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
|
||||
- Lange IDs (UUID‑artig) eignen sich für Wiederverwendung über Sitzungen/Geräte hinweg – am besten mit dem Cache‑Button kombinieren.
|
||||
|
||||
---
|
||||
|
||||
## Ausprobieren (Hands‑on)
|
||||
|
||||
Schnelltest am Desktop:
|
||||
|
||||
1. Auf Sender‑Seite eine benutzerdefinierte ID (≥ 8 Zeichen) eingeben und „ID speichern“ klicken.
|
||||
2. Zum Empfänger wechseln: Wenn die Bedingungen passen, wird automatisch ausgefüllt und beigetreten.
|
||||
3. Ausfall simulieren (WLAN aus, Hotspot an, refresh & zurück) und die Auto‑Wiederverbindung beobachten.
|
||||
4. Auf Sender‑Seite „Gecachte ID verwenden“ doppelklicken, kurzzeitig auf „ID speichern“ umschalten und auf eine neue lange ID aktualisieren.
|
||||
|
||||
Mobil/Problemnetze:
|
||||
|
||||
- Hintergrund → Vordergrund; Wechsel WLAN ↔ Mobilfunk.
|
||||
- Prüfen, ob der Empfänger auto‑beitritt und die Übertragung nahtlos fortsetzt.
|
||||
|
||||
---
|
||||
|
||||
## Schluss & Aufruf
|
||||
|
||||
Je geschmeidiger die Verbindung, desto größer der P2P‑Wert. Cached‑ID‑Auto‑Join und robuste Wiederverbindung machen PrivyDrop im echten Netz noch verlässlicher.
|
||||
|
||||
Wenn Ihnen das gefällt, freuen wir uns über einen Stern auf GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>). Das hilft, entdeckt zu werden – und treibt uns an, weiter zu feilen.
|
||||
|
||||
Jetzt online testen: <u>https://www.privydrop.app</u>. Feedback und Verbesserungsvorschläge gern als Issue – helfen Sie uns, das „glatte Gefühl“ weiter auszubauen.
|
||||
|
||||
Zusätzlich sorgt Cloudflare CDN für Beschleunigung über Regionen hinweg – schnellere, stabilere Zugriffe, weniger Ruckler.
|
||||
|
||||
Weiterführende Lektüre:
|
||||
|
||||
- [Warum ich PrivyDrop Open Source gestellt habe](/blog/privydrop-open-source)
|
||||
- [Wie WebRTC Browser‑Direkttransfer ermöglicht](/blog/webRTC-file-transfer)
|
||||
- [Resumable Transfers: Schluss mit der Großdatei‑Anxiety](/blog/resumable-transfers)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "One‑Click Reconnect, Rock‑Solid: A Deep Dive into Cached‑ID Auto‑Join and Resilient Reconnect in PrivyDrop"
|
||||
description: "New on the receiver: cached‑ID auto‑join and full‑path reconnect for smoother flows — auto room join, one‑tap direct connect, double‑tap to update the cache, and steady recovery on flaky networks."
|
||||
date: "2025-11-25"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/cached-id-reconnect.webp"
|
||||
tags: ["New Feature", "Auto Reconnect", "Cached ID", "WebRTC", "P2P"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introduction: Why “Auto‑Join” and “Reconnect” Matter
|
||||
|
||||
New users of PrivyDrop often run into two tiny frictions:
|
||||
|
||||
- When switching from Sender to Receiver, you have to paste the room ID again.
|
||||
- On café Wi‑Fi or mobile data, a brief blip means a manual reconnect.
|
||||
|
||||
Tiny? Yes. Frequent in real‑world networks? Absolutely. And they decide whether an app feels “effortless.” So we shipped two polish‑level upgrades that make the flow truly smooth:
|
||||
|
||||
- Receiver “Cached‑ID Auto‑Join”: when conditions match, we auto‑fill and join the room for you.
|
||||
- End‑to‑end “Resilient Reconnect”: whether Socket or P2P drops, negotiation and connection recover on their own.
|
||||
|
||||
Most importantly, none of this changes our red‑line architecture: the backend only handles signaling and room management; files are always end‑to‑end encrypted and go directly browser‑to‑browser.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Receiver Cached‑ID Auto‑Join
|
||||
|
||||
When you switch to the Receiver tab, if the following conditions are met, the last cached room ID will be auto‑filled and the app will immediately join the room:
|
||||
|
||||
- You’re on the Receiver tab and not already in a room;
|
||||
- The URL has no explicit `roomId` param (URL wins — we don’t override);
|
||||
- The input is currently empty (we don’t override your typing);
|
||||
- A cached ID exists in localStorage.
|
||||
|
||||
This logic triggers on tab switch. If matched, we first fill the input, then immediately call the join routine—one less paste/click.
|
||||
|
||||
- Code anchors:
|
||||
- Receiver‑side auto‑join useEffect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
|
||||
- Cache helper (localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
|
||||
|
||||
When will it not trigger?
|
||||
|
||||
- You’re already in a room;
|
||||
- The URL explicitly carries a `roomId` (e.g., a shared deep link);
|
||||
- There’s already text in the input that you’re editing;
|
||||
- No cached ID is found.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Sender “Save/Use Cached ID” (Double‑Tap to Update)
|
||||
|
||||
On the Sender side, the room ID field gets a smart “Reuse” button that toggles between two states:
|
||||
|
||||
- Save ID: when the input length is ≥ 8, the button becomes active; clicking saves the current input as the cached ID.
|
||||
- Use Cached ID: if a cached ID exists, a single tap writes it into the input and joins immediately; a double‑tap flips the button to “Save ID” for about 3 seconds so you can refresh the cache.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Single/double taps use a 400ms window with a timer that’s cleaned up on unmount;
|
||||
- After “Use Cached ID” is clicked, the Sender joins the room immediately (no extra “Join” click);
|
||||
- We don’t allow saving IDs shorter than 8 chars to avoid accidental short saves.
|
||||
|
||||
- Code anchors:
|
||||
- Single/double‑tap with timer cleanup: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
|
||||
- Auto‑join immediately on “Use Cached ID” (Sender): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
|
||||
|
||||
---
|
||||
|
||||
## Reconnect: From Detection to Full Recovery
|
||||
|
||||
We watch for disconnects from three entry points and trigger reconnection:
|
||||
|
||||
- Socket disconnected: after reconnecting, if `socketId` changes, we auto re‑join the room;
|
||||
- P2P disconnected/failed/closed: we flag state and attempt to rebuild the connection;
|
||||
- Proactive `socketId` change check: on socket recovery, we validate once more.
|
||||
|
||||
- Code anchors:
|
||||
- Auto re‑join after socket connects: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
|
||||
- Unified attemptReconnection entry: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
|
||||
- Track `lastJoinedSocketId` and trigger `initiator-online` when needed: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
|
||||
- Sender handles `recipient-ready` and restarts negotiation: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
|
||||
- Receiver responds to `initiator-online` with `recipient-ready`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
|
||||
- Backend signaling relay:
|
||||
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
|
||||
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
|
||||
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
|
||||
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
|
||||
|
||||
### Sequence (Mermaid)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Signaling Server
|
||||
participant A as Sender (initiator)
|
||||
participant B as Receiver (recipient)
|
||||
|
||||
Note over A,B: Network blips cause Socket/P2P disconnects
|
||||
A->>A: attemptReconnection()
|
||||
A->>S: join(roomId) / (maybe) initiator-online
|
||||
S-->>B: initiator-online
|
||||
B->>S: recipient-ready(peerId)
|
||||
S-->>A: recipient-ready(peerId)
|
||||
A->>B: offer
|
||||
B->>A: answer
|
||||
A-->>B: ICE candidates
|
||||
B-->>A: ICE candidates
|
||||
Note over A,B: Connection restored, DataChannel re-established
|
||||
```
|
||||
|
||||
### Reliability Details
|
||||
|
||||
- ICE candidate queue: if the remote description isn’t ready or the connection is closing/closed, candidates are queued and flushed later; see https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
|
||||
- DataChannel backpressure & chunking: Sender threshold `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); network control `maxBuffer≈3MB / lowThreshold≈512KB / 64KB chunks` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
|
||||
- Mobile wake lock: request Wake Lock when connected; release on disconnect/failure to reduce background interruptions.
|
||||
- Error wrapping & retries: rare `sendData failed` paths are wrapped, surfaced, and retried (see `sendWithBackpressure`).
|
||||
|
||||
### Short vs Long IDs: Reuse Strategy
|
||||
|
||||
- Short IDs (4‑digit) get a 15‑minute (900s) grace TTL when a room becomes empty after a disconnect—allowing quick reconnection within the window; see https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
|
||||
- Default room expiry is 24 hours; only empty‑room disconnects switch to the temporary 15‑minute keepalive; see https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
|
||||
- Long IDs (UUID‑like) are better for cross‑session/cross‑device reuse; pair them with the cached‑ID button for best ergonomics.
|
||||
|
||||
---
|
||||
|
||||
## Try It (Hands‑On)
|
||||
|
||||
Desktop quick try:
|
||||
|
||||
1. On the Sender, enter a custom ID with ≥ 8 characters and click “Save ID”.
|
||||
2. Switch to the Receiver: if conditions match, it auto‑fills and joins the room.
|
||||
3. Simulate a dropout (turn Wi‑Fi off, switch to hotspot, refresh and return) and watch it reconnect automatically.
|
||||
4. On the Sender, double‑tap “Use Cached ID” to temporarily switch to “Save ID” and update to a new long ID.
|
||||
|
||||
Mobile/poor network scenarios:
|
||||
|
||||
- Background → foreground; switch Wi‑Fi ↔ cellular.
|
||||
- Observe whether the Receiver auto‑joins, and whether transfer resumes automatically.
|
||||
|
||||
---
|
||||
|
||||
## Wrap‑Up & Call to Action
|
||||
|
||||
Smoother connections amplify the value of P2P. Cached‑ID auto‑join on the receiver and resilient reconnect across the stack make PrivyDrop sturdier and more dependable in the real world.
|
||||
|
||||
If you find this useful, please star us on GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>) so more people can discover it. Your star directly affects search and recommendation signals—and fuels our motivation to keep polishing.
|
||||
|
||||
Try it online: <u>https://www.privydrop.app</u>. We also welcome issues with your feedback and suggestions—help us make the “smooth experience” even smoother.
|
||||
|
||||
Additionally, our domain is accelerated via Cloudflare CDN (saintly cyber help), significantly improving cross‑region speed and stability so more users can open the site without hiccups.
|
||||
|
||||
Further Reading:
|
||||
|
||||
- [Why I Open‑Sourced PrivyDrop](/blog/privydrop-open-source)
|
||||
- [How WebRTC Enables Browser‑Direct Transfer](/blog/webRTC-file-transfer)
|
||||
- [Resumable Transfers: Say Goodbye to Big‑File Anxiety](/blog/resumable-transfers)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "Un toque y de vuelta, sólido como roca: Auto‑unión por ID en caché y reconexión resiliente en PrivyDrop"
|
||||
description: "Novedad en el receptor: auto‑entrada mediante ID en caché y reconexión de extremo a extremo para un flujo más suave — entrada automática, conexión directa con un toque, doble toque para actualizar la caché y recuperación firme en redes inestables."
|
||||
date: "2025-11-25"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/cached-id-reconnect.webp"
|
||||
tags: ["Nueva función", "Reconexión automática", "ID en caché", "WebRTC", "P2P"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introducción: por qué importan el “auto‑join” y la “reconexión”
|
||||
|
||||
Quien prueba PrivyDrop por primera vez tropieza con dos fricciones pequeñas pero frecuentes:
|
||||
|
||||
- Al pasar de Enviar a Recibir, hay que pegar de nuevo el ID de sala.
|
||||
- En Wi‑Fi de cafetería o datos móviles, un microcorte obliga a reconectar manualmente.
|
||||
|
||||
Pequeñeces, sí. Pero en red real aparecen mucho y deciden si algo se siente “sin esfuerzo”. Por eso lanzamos dos mejoras de acabado que pulen el flujo hasta hacerlo realmente suave:
|
||||
|
||||
- “Auto‑unión por ID en caché” en el receptor: si se cumplen las condiciones, rellenamos y entramos en la sala automáticamente.
|
||||
- “Reconexion resiliente” de extremo a extremo: caiga Socket o P2P, la negociación y la conexión se recuperan solas.
|
||||
|
||||
Y lo más importante: no tocamos nuestra línea roja arquitectónica. El backend solo hace señalización y salas; los archivos siempre viajan de navegador a navegador con cifrado de extremo a extremo.
|
||||
|
||||
---
|
||||
|
||||
## Función 1: Auto‑unión del receptor con ID en caché
|
||||
|
||||
Al cambiar a la pestaña de Receptor, si se cumplen estas condiciones, rellenamos el último ID de sala guardado y entramos al instante:
|
||||
|
||||
- Estás en la pestaña de Recibir y aún no estás en una sala;
|
||||
- La URL no incluye `roomId` (la URL manda; no sobrescribimos);
|
||||
- El campo de entrada está vacío (no pisamos lo que escribes);
|
||||
- Existe un ID en caché en localStorage.
|
||||
|
||||
La lógica se dispara al cambiar de pestaña: primero rellenamos, luego llamamos directamente a unirse—un pegado/clic menos.
|
||||
|
||||
- Anclas de código:
|
||||
- useEffect de auto‑entrada en el receptor: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
|
||||
- Utilidad de caché (localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
|
||||
|
||||
¿Cuándo no se activa?
|
||||
|
||||
- Ya estás dentro de una sala;
|
||||
- La URL trae `roomId` explícito (por ejemplo, enlace compartido con parámetro);
|
||||
- El campo de entrada ya contiene texto en edición;
|
||||
- No hay ID en caché.
|
||||
|
||||
---
|
||||
|
||||
## Función 2: “Guardar/Usar ID en caché” en el emisor (doble toque para actualizar)
|
||||
|
||||
En el lado Emisor, el campo del ID incorpora un botón inteligente de “Reusar” con dos estados:
|
||||
|
||||
- Guardar ID: con longitud ≥ 8, el botón se habilita; al pulsar, guarda el texto actual como ID en caché.
|
||||
- Usar ID en caché: si existe, un toque lo escribe en el campo y se une de inmediato; con doble toque, el botón pasa durante ~3 s a “Guardar ID” para que puedas actualizar la caché.
|
||||
|
||||
Notas de implementación:
|
||||
|
||||
- El simple/doble toque se decide en una ventana de 400 ms con temporizador, limpiado al desmontar;
|
||||
- Tras “Usar ID en caché”, el emisor entra a la sala inmediatamente (sin pulsar “Unirse”);
|
||||
- No permitimos guardar IDs de menos de 8 caracteres para evitar guardados accidentales.
|
||||
|
||||
- Anclas de código:
|
||||
- Simple/doble toque y limpieza del temporizador: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
|
||||
- Unión inmediata al “Usar ID en caché” (emisor): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
|
||||
|
||||
---
|
||||
|
||||
## Reconexión: de la detección a la recuperación completa
|
||||
|
||||
Observamos tres puntos para detectar cortes y disparar la reconexión:
|
||||
|
||||
- Socket desconectado: si al volver cambia el `socketId`, re‑entramos a la sala automáticamente;
|
||||
- P2P desconectado/fallido/cerrado: marcamos estado e intentamos reconstruir la conexión;
|
||||
- Comprobación proactiva de cambio de `socketId`: al recuperar el socket, validamos de nuevo.
|
||||
|
||||
- Anclas de código:
|
||||
- Re‑entrada automática tras reconectar el socket: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
|
||||
- Punto unificado de attemptReconnection: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
|
||||
- Registro de `lastJoinedSocketId` y envío de `initiator-online` si procede: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
|
||||
- El emisor recibe `recipient-ready` y reinicia la negociación: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
|
||||
- El receptor responde a `initiator-online` con `recipient-ready`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
|
||||
- Relé de señalización en backend:
|
||||
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
|
||||
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
|
||||
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
|
||||
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
|
||||
|
||||
### Secuencia (Mermaid)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Signaling Server
|
||||
participant A as Emisor (initiator)
|
||||
participant B as Receptor (recipient)
|
||||
|
||||
Note over A,B: Microcortes provocan desconexión de Socket/P2P
|
||||
A->>A: attemptReconnection()
|
||||
A->>S: join(roomId) / (quizá) initiator-online
|
||||
S-->>B: initiator-online
|
||||
B->>S: recipient-ready(peerId)
|
||||
S-->>A: recipient-ready(peerId)
|
||||
A->>B: offer
|
||||
B->>A: answer
|
||||
A-->>B: ICE candidates
|
||||
B-->>A: ICE candidates
|
||||
Note over A,B: Conexión restaurada, DataChannel restablecido
|
||||
```
|
||||
|
||||
### Detalles de fiabilidad
|
||||
|
||||
- Cola de candidatos ICE: si la descripción remota no está lista o la conexión se cierra, los candidatos se encolan y se envían después; ver https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
|
||||
- Backpressure y troceado del DataChannel: umbral del emisor `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); control de red `maxBuffer≈3MB / lowThreshold≈512KB / trozos de 64KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
|
||||
- Wake Lock móvil: se solicita al conectar y se libera al desconectar/fallar, para reducir interrupciones al pasar a segundo plano.
|
||||
- Envoltorio de errores y reintentos: los raros `sendData failed` se capturan, se muestran y se reintentan (ver `sendWithBackpressure`).
|
||||
|
||||
### Estrategia de reutilización: IDs cortos vs largos
|
||||
|
||||
- IDs cortos (4 dígitos) reciben un TTL de 15 minutos (900s) cuando la sala queda vacía tras la desconexión; permite reconectar fácilmente en esa ventana; ver https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
|
||||
- La expiración por defecto de la sala es 24 horas; solo en “sala vacía + desconexión” pasamos a la retención temporal de 15 minutos; ver https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
|
||||
- IDs largos (tipo UUID) son mejores para reutilización entre sesiones/dispositivos; combínalos con el botón de caché para la mejor ergonomía.
|
||||
|
||||
---
|
||||
|
||||
## Cómo probarlo (hands‑on)
|
||||
|
||||
Prueba rápida en escritorio:
|
||||
|
||||
1. En el Emisor, escribe un ID personalizado de ≥ 8 caracteres y pulsa “Guardar ID”.
|
||||
2. Cambia al Receptor: si se cumplen condiciones, se rellenará y entrará automáticamente.
|
||||
3. Simula un corte (apaga el Wi‑Fi, cambia a hotspot, recarga y vuelve) y observa la reconexión automática.
|
||||
4. En el Emisor, haz doble toque en “Usar ID en caché” para cambiar temporalmente a “Guardar ID” y actualizar a un ID largo nuevo.
|
||||
|
||||
Móvil/redes pobres:
|
||||
|
||||
- Segundo plano → primer plano; cambia entre Wi‑Fi ↔ datos.
|
||||
- Observa si el receptor se auto‑une y si la transferencia se reanuda sola.
|
||||
|
||||
---
|
||||
|
||||
## Cierre y llamada a la acción
|
||||
|
||||
Cuanto más suave es la conexión, más crece el valor del P2P. El auto‑join por ID en caché y la reconexión resiliente hacen que PrivyDrop sea más robusto y confiable en redes reales.
|
||||
|
||||
Si te resulta útil, déjanos una estrella en GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>) para que más personas nos encuentren. Tu estrella impacta en búsqueda y recomendaciones—y alimenta nuestras ganas de seguir puliendo.
|
||||
|
||||
Pruébalo online: <u>https://www.privydrop.app</u>. También te invitamos a abrir issues con comentarios y sugerencias para seguir afinando esa “experiencia sin fricción”.
|
||||
|
||||
Además, el dominio está acelerado con Cloudflare CDN, mejorando notablemente velocidad y estabilidad entre regiones para una apertura sin tirones.
|
||||
|
||||
Lecturas recomendadas:
|
||||
|
||||
- [Por qué hice PrivyDrop de código abierto](/blog/privydrop-open-source)
|
||||
- [Cómo WebRTC permite la transferencia directa entre navegadores](/blog/webRTC-file-transfer)
|
||||
- [Transferencias reanudables: adiós a la ansiedad por los archivos grandes](/blog/resumable-transfers)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "Un clic et ça repart, solide comme un roc : Auto‑join via ID en cache et reconnexion résiliente dans PrivyDrop"
|
||||
description: "Nouveauté côté réception : auto‑entrée grâce à l’ID en cache et reconnexion de bout en bout pour un flux plus fluide — entrée automatique, connexion directe en un clic, double‑clic pour mettre à jour le cache, et reprise fiable sur réseau instable."
|
||||
date: "2025-11-25"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/cached-id-reconnect.webp"
|
||||
tags: ["Nouvelle fonctionnalité", "Reconnexion automatique", "ID en cache", "WebRTC", "P2P"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introduction : pourquoi « auto‑join » et « reconnexion »
|
||||
|
||||
Les nouveaux utilisateurs de PrivyDrop rencontrent souvent deux petites frictions :
|
||||
|
||||
- En passant d’Envoyer à Recevoir, il faut recoller l’ID de salle ;
|
||||
- Sur un Wi‑Fi de café ou en 4G, une micro‑coupure impose une reconnexion manuelle.
|
||||
|
||||
Des détails ? Oui. Mais très fréquents dans le monde réel — ils font la différence entre « ça marche » et « c’est fluide ». Nous avons donc livré deux finitions qui rendent l’expérience vraiment soyeuse :
|
||||
|
||||
- « Auto‑join via ID en cache » côté récepteur : si les conditions sont réunies, on pré‑remplit et on rejoint la salle automatiquement ;
|
||||
- « Reconnexion résiliente » de bout en bout : que Socket ou P2P tombe, la négociation et la connexion se rétablissent seules.
|
||||
|
||||
Le tout sans toucher à notre ligne rouge architecturale : le backend ne fait que la signalisation et la gestion de salle ; les fichiers restent chiffrés de bout en bout, directement de navigateur à navigateur.
|
||||
|
||||
---
|
||||
|
||||
## Fonction 1 : Auto‑join du récepteur avec ID en cache
|
||||
|
||||
Lorsque vous passez à l’onglet Récepteur, si les conditions suivantes sont réunies, le dernier ID de salle en cache est pré‑rempli et l’entrée est immédiate :
|
||||
|
||||
- Vous êtes sur l’onglet Récepteur et pas encore dans une salle ;
|
||||
- L’URL ne contient pas `roomId` (l’URL l’emporte — pas d’écrasement) ;
|
||||
- Le champ de saisie est vide (on ne remplace pas votre saisie) ;
|
||||
- Un ID en cache existe dans le localStorage.
|
||||
|
||||
La logique se déclenche au changement d’onglet. Si c’est bon, on remplit d’abord, puis on appelle aussitôt la routine d’entrée — un collage/clic de moins.
|
||||
|
||||
- Repères de code :
|
||||
- useEffect d’auto‑entrée côté récepteur : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
|
||||
- Utilitaire de cache (localStorage) : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
|
||||
|
||||
Quand cela ne s’applique‑t‑il pas ?
|
||||
|
||||
- Vous êtes déjà dans une salle ;
|
||||
- L’URL porte explicitement `roomId` (lien de partage avec paramètre) ;
|
||||
- Le champ contient déjà un texte en cours de saisie ;
|
||||
- Aucun ID en cache n’est trouvé.
|
||||
|
||||
---
|
||||
|
||||
## Fonction 2 : « Enregistrer / Utiliser l’ID en cache » côté émetteur (double‑clic pour mettre à jour)
|
||||
|
||||
Sur l’émetteur, le champ d’ID accueille un bouton « Réutiliser » astucieux qui alterne entre deux états :
|
||||
|
||||
- Enregistrer l’ID : quand la longueur ≥ 8, le bouton s’active ; un clic enregistre la saisie courante comme ID en cache.
|
||||
- Utiliser l’ID en cache : s’il existe, un clic l’insère et rejoint la salle immédiatement ; un double‑clic bascule ~3 s en « Enregistrer l’ID » pour actualiser le cache.
|
||||
|
||||
Notes d’implémentation :
|
||||
|
||||
- Simple/double‑clic via une fenêtre de 400 ms, timer nettoyé au démontage ;
|
||||
- Après « Utiliser l’ID en cache », l’émetteur rejoint la salle immédiatement (pas de clic « Rejoindre » supplémentaire) ;
|
||||
- Pas d’enregistrement d’ID de moins de 8 caractères pour éviter les « courts » accidentels.
|
||||
|
||||
- Repères de code :
|
||||
- Simple/double‑clic et nettoyage du timer : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
|
||||
- Rejoindre immédiatement après « Utiliser l’ID en cache » (émetteur) : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
|
||||
|
||||
---
|
||||
|
||||
## Reconnexion : de la détection au rétablissement complet
|
||||
|
||||
Nous surveillons trois points d’entrée et déclenchons la reconnexion :
|
||||
|
||||
- Socket déconnecté : après reconnexion, si le `socketId` change, on ré‑entre automatiquement ;
|
||||
- P2P déconnecté/échec/fermé : on marque l’état et on tente de reconstruire la connexion ;
|
||||
- Vérification proactive de changement de `socketId` : à la reprise du socket, on revalide.
|
||||
|
||||
- Repères de code :
|
||||
- Ré‑entrée auto après connexion du socket : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
|
||||
- Point d’entrée unifié attemptReconnection : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
|
||||
- Suivi de `lastJoinedSocketId` et émission de `initiator-online` si nécessaire : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
|
||||
- Côté émetteur, réception de `recipient-ready` et reprise de la négociation : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
|
||||
- Côté récepteur, réponse `recipient-ready` à `initiator-online` : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
|
||||
- Relais côté backend :
|
||||
- ready : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
|
||||
- initiator-online : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
|
||||
- recipient-ready : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
|
||||
- peer-disconnected : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
|
||||
|
||||
### Séquence (Mermaid)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Signaling Server
|
||||
participant A as Émetteur (initiator)
|
||||
participant B as Récepteur (recipient)
|
||||
|
||||
Note over A,B: Les aléas réseau coupent Socket/P2P
|
||||
A->>A: attemptReconnection()
|
||||
A->>S: join(roomId) / (évent.) initiator-online
|
||||
S-->>B: initiator-online
|
||||
B->>S: recipient-ready(peerId)
|
||||
S-->>A: recipient-ready(peerId)
|
||||
A->>B: offer
|
||||
B->>A: answer
|
||||
A-->>B: ICE candidates
|
||||
B-->>A: ICE candidates
|
||||
Note over A,B: Connexion restaurée, DataChannel ré‑établi
|
||||
```
|
||||
|
||||
### Détails de fiabilité
|
||||
|
||||
- File d’attente des candidats ICE : si la description distante n’est pas prête ou que la connexion se ferme, on met en file et on rejoue plus tard ; voir https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
|
||||
- Rétro‑pression et découpage DataChannel : seuil émetteur `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82) ; contrôle réseau `maxBuffer≈3MB / lowThreshold≈512KB / chunks de 64KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
|
||||
- Wake Lock mobile : demande à l’établissement de la connexion, libération à la déconnexion/échec — pour réduire les interruptions en arrière‑plan.
|
||||
- Encapsulation d’erreurs et retries : les rares `sendData failed` sont capturés, surfacés et réessayés (voir `sendWithBackpressure`).
|
||||
|
||||
### Stratégie de réutilisation : IDs courts vs longs
|
||||
|
||||
- IDs courts (4 chiffres) : en cas de « salle vide + déconnexion », TTL de grâce de 15 minutes (900s) — reconnexion rapide dans la fenêtre ; voir https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
|
||||
- Expiration par défaut : 24 h ; seul le cas « salle vide + déconnexion » passe en conservation temporaire de 15 min ; voir https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
|
||||
- IDs longs (type UUID) : mieux pour la réutilisation inter‑sessions/appareils ; les combiner avec le bouton d’ID en cache offre la meilleure ergonomie.
|
||||
|
||||
---
|
||||
|
||||
## Prise en main (hands‑on)
|
||||
|
||||
Essai rapide sur desktop :
|
||||
|
||||
1. Côté Émetteur, entrez un ID personnalisé (≥ 8 caractères) et cliquez « Enregistrer l’ID ».
|
||||
2. Passez au Récepteur : si les conditions sont réunies, auto‑remplissage et entrée immédiate.
|
||||
3. Simulez une coupure (coupure Wi‑Fi, bascule hotspot, actualiser puis revenir) et observez la reconnexion automatique.
|
||||
4. Côté Émetteur, double‑cliquez « Utiliser l’ID en cache » pour basculer brièvement en « Enregistrer l’ID » et mettre à jour vers un nouvel ID long.
|
||||
|
||||
Mobile / réseaux difficiles :
|
||||
|
||||
- Arrière‑plan → premier plan ; bascule Wi‑Fi ↔ cellulaire.
|
||||
- Vérifiez l’auto‑entrée du Récepteur et la reprise automatique du transfert.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion & appel à l’action
|
||||
|
||||
Plus la connexion est fluide, plus la valeur du P2P grandit. L’auto‑join via ID en cache et la reconnexion résiliente renforcent la robustesse de PrivyDrop dans les réseaux réels.
|
||||
|
||||
Si vous aimez, mettez‑nous une étoile sur GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>) — cela accroît la visibilité et nourrit notre envie de peaufiner.
|
||||
|
||||
Essai en ligne : <u>https://www.privydrop.app</u>. Vos retours et idées sont bienvenus via les issues : continuons ensemble à polir « l’expérience soyeuse ».
|
||||
|
||||
Par ailleurs, notre domaine bénéficie de l’accélération Cloudflare CDN, améliorant nettement vitesse et stabilité inter‑régions.
|
||||
|
||||
Pour aller plus loin :
|
||||
|
||||
- [Pourquoi j’ai open‑sourcé PrivyDrop](/blog/privydrop-open-source)
|
||||
- [Comment WebRTC permet le transfert direct entre navigateurs](/blog/webRTC-file-transfer)
|
||||
- [Transferts reprenables : adieu à l’anxiété des gros fichiers](/blog/resumable-transfers)
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "ワンタップで復帰、盤石の安定性:PrivyDrop のキャッシュID自動参加と切断時の再接続を徹底解説"
|
||||
description: "受信側のキャッシュID自動入室と全経路の自動再接続で、体験はより滑らかに。自動入室、ワンタップ直結、ダブルタップでキャッシュ更新、そして不安定なネットでも粘り強く復帰します。"
|
||||
date: "2025-11-25"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/cached-id-reconnect.webp"
|
||||
tags: ["新機能", "自動再接続", "キャッシュID", "WebRTC", "P2P"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## はじめに:なぜ「自動入室」と「再接続」なのか
|
||||
|
||||
PrivyDrop を初めて使うと、よくある小さな引っかかりが二つあります。
|
||||
|
||||
- 送信から受信に切り替えるたび、部屋の ID をもう一度貼り付ける。
|
||||
- カフェの Wi‑Fi やモバイル回線で一瞬切れると、手動でつなぎ直す。
|
||||
|
||||
小さなこと。でも現実のネットワークでは頻出で、使い心地を左右します。そこで私たちは、体験を“するり”と滑らかにする二つの磨き込みを加えました。
|
||||
|
||||
- 受信側「キャッシュIDの自動入室」:条件を満たせば、自動で入力&即入室。
|
||||
-, エンドツーエンドの「粘り強い再接続」:Socket / P2P のどちらが落ちても、自動で再ネゴシエーション&復旧。
|
||||
|
||||
そして大切なのは、アーキテクチャのレッドラインは不変であること。バックエンドは信令とルーム管理のみ、ファイルは常に E2E 暗号化でブラウザ間を直送します。
|
||||
|
||||
---
|
||||
|
||||
## 機能1:受信側のキャッシュID自動入室
|
||||
|
||||
受信タブへ切り替えた際、以下の条件を満たすと、最後に保存した部屋 ID を自動入力し、すぐ入室します。
|
||||
|
||||
- 受信タブにいて、まだ入室していない;
|
||||
- URL に `roomId` パラメータがない(URL が優先、上書きしない);
|
||||
- 入力欄が空(ユーザーの入力は上書きしない);
|
||||
- localStorage にキャッシュ ID が存在する。
|
||||
|
||||
この判定はタブ切り替え時に走ります。条件一致なら、入力欄を埋めてからそのまま入室ロジックを呼び出し、貼り付け/クリックを 1 回減らします。
|
||||
|
||||
- コード参照:
|
||||
- 受信側の自動入室 useEffect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
|
||||
- キャッシュユーティリティ(localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
|
||||
|
||||
発動しないとき:
|
||||
|
||||
- すでに入室している;
|
||||
- URL が明示的に `roomId` を持つ(共有リンクなど);
|
||||
- 入力欄に既に文字があり編集中;
|
||||
- キャッシュ ID が存在しない。
|
||||
|
||||
---
|
||||
|
||||
## 機能2:送信側の「保存/使用」ボタン(ダブルタップで更新)
|
||||
|
||||
送信側の部屋 ID 入力欄に、賢い「再利用」ボタンを追加しました。状態は 2 つに切り替わります。
|
||||
|
||||
- ID を保存:入力長が 8 文字以上で有効化。クリックで現在の入力をキャッシュ ID として保存。
|
||||
- キャッシュ ID を使用:キャッシュがあれば、ワンタップで入力欄に反映してそのまま入室。ダブルタップすると約 3 秒だけ「ID を保存」に切り替わり、キャッシュを更新できます。
|
||||
|
||||
実装メモ:
|
||||
|
||||
- シングル/ダブルタップは 400ms の判定窓+タイマーで実現し、アンマウント時にクリーンアップ;
|
||||
- 「キャッシュ ID を使用」後は送信側が即入室(追加の「入室」操作は不要);
|
||||
- 8 文字未満は保存不可にして、短い ID の誤保存を防止。
|
||||
|
||||
- コード参照:
|
||||
- シングル/ダブルタップとタイマーのクリーンアップ: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
|
||||
- 「キャッシュ ID を使用」で即入室(送信側): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
|
||||
|
||||
---
|
||||
|
||||
## 再接続:検知から復旧までの流れ
|
||||
|
||||
私たちは 3 つの入口から「切断」を監視し、再接続を走らせます。
|
||||
|
||||
- Socket 切断:再接続後に `socketId` が変わっていれば自動再入室;
|
||||
- P2P 切断/失敗/クローズ:状態をマーキングし、接続再構築を試行;
|
||||
- `socketId` の変化を能動チェック:Socket 復旧時に再確認。
|
||||
|
||||
- コード参照:
|
||||
- Socket 接続後の自動再入室: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
|
||||
- 再接続の統一エントリ: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
|
||||
- `lastJoinedSocketId` の記録と必要時の `initiator-online` 送出: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
|
||||
- 送信側の `recipient-ready` 受信と再ネゴシエーション開始: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
|
||||
- 受信側の `initiator-online` 受信と `recipient-ready` 応答: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
|
||||
- バックエンドの信令リレー:
|
||||
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
|
||||
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
|
||||
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
|
||||
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
|
||||
|
||||
### シーケンス(Mermaid)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Signaling Server
|
||||
participant A as 送信側(initiator)
|
||||
participant B as 受信側(recipient)
|
||||
|
||||
Note over A,B: ネットの揺らぎで Socket/P2P が切断
|
||||
A->>A: attemptReconnection()
|
||||
A->>S: join(roomId) / (場合により)initiator-online
|
||||
S-->>B: initiator-online
|
||||
B->>S: recipient-ready(peerId)
|
||||
S-->>A: recipient-ready(peerId)
|
||||
A->>B: offer
|
||||
B->>A: answer
|
||||
A-->>B: ICE candidates
|
||||
B-->>A: ICE candidates
|
||||
Note over A,B: 接続回復、DataChannel 再確立
|
||||
```
|
||||
|
||||
### 信頼性ディテール
|
||||
|
||||
- ICE 候補キュー:リモート記述が未確立、または接続がクローズ系なら候補をキューイングし、後でまとめて反映;https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256。
|
||||
- DataChannel の背圧と分割送信:送信側しきい値 `bufferedAmountLowThreshold=256KB`(https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82);ネットワーク制御は `maxBuffer≈3MB / lowThreshold≈512KB / 64KB チャンク`(https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210)。
|
||||
- モバイルの Wake Lock:接続時に取得、切断/失敗で解放。バックグラウンド遷移による中断を低減。
|
||||
- エラー包みとリトライ:まれな `sendData failed` を捕捉し、表面化&再試行(`sendWithBackpressure` を参照)。
|
||||
|
||||
### 短い ID と長い ID の使い分け
|
||||
|
||||
- 短い ID(4 桁)は「空室で切断」時、バックエンドが 15 分(900s)の TTL に更新。猶予内は再接続が容易;https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125。
|
||||
- 既定の部屋期限は 24 時間。空室切断のときのみ 15 分の一時保持に切替;https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6。
|
||||
- 長い ID(UUID 相当)はセッション横断・デバイス横断の再利用に向く。キャッシュ ID ボタンと組み合わせると最良。
|
||||
|
||||
---
|
||||
|
||||
## 触ってみる(クイックスタート)
|
||||
|
||||
デスクトップでの手早い体験:
|
||||
|
||||
1. 送信側で 8 文字以上の任意 ID を入力し、「ID を保存」をクリック。
|
||||
2. 受信側へ切り替え:条件を満たせば自動入力&即入室。
|
||||
3. 切断を再現(Wi‑Fi を切る、テザリングへ切替、リロード→戻る)して、自動復帰を観察。
|
||||
4. 送信側で「キャッシュ ID を使用」をダブルタップし、一時的に「ID を保存」に切替→新しい長い ID へ更新。
|
||||
|
||||
モバイル/弱い回線の場面:
|
||||
|
||||
- バックグラウンド→フォアグラウンド、Wi‑Fi とセルラーの切替。
|
||||
- 受信側の自動入室や、転送の自動再開を確認。
|
||||
|
||||
---
|
||||
|
||||
## 結びとお願い
|
||||
|
||||
“するり”とつながるほど、P2P の価値は増幅します。受信側のキャッシュ ID 自動入室と、スタック全体の再接続により、PrivyDrop は現実のネット環境でいっそう頑丈で頼れる存在になりました。
|
||||
|
||||
もし気に入っていただけたら、ぜひ GitHub で Star をお願いします(<u>https://github.com/david-bai00/PrivyDrop</u>)。見つけてもらいやすくなるだけでなく、私たちの磨き込みの原動力にもなります。
|
||||
|
||||
オンライン体験:<u>https://www.privydrop.app</u>。Issue から体験フィードバックや改善提案も歓迎します。“なめらかな体験”を、さらに厚くしていきましょう。
|
||||
|
||||
なお、ドメインは Cloudflare CDN による加速を有効化。地域間の速度と安定性が向上し、より多くのユーザーがストレスなくアクセスできます。
|
||||
|
||||
関連記事:
|
||||
|
||||
- [なぜ PrivyDrop をオープンソース化したのか](/blog/privydrop-open-source)
|
||||
- [WebRTC はどうやってブラウザ直送を実現するのか](/blog/webRTC-file-transfer)
|
||||
- [レジューム転送:大容量でも焦らない](/blog/resumable-transfers)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "원탭 재연결, 바위처럼 단단하게: PrivyDrop의 캐시 ID 자동 입장과 탄탄한 재연결 완전 해부"
|
||||
description: "받는 쪽 캐시 ID 자동 입장과 전 구간 재연결로 흐름이 더 매끄럽게—자동 입장, 한 번 탭으로 즉시 연결, 두 번 탭으로 캐시 갱신, 불안정한 네트워크에서도 끈질긴 복구."
|
||||
date: "2025-11-25"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/cached-id-reconnect.webp"
|
||||
tags: ["신기능", "자동 재연결", "캐시된 ID", "WebRTC", "P2P"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## 소개: 왜 “자동 입장”과 “재연결”인가
|
||||
|
||||
PrivyDrop을 처음 쓰면 자주 마주치는 두 가지 작은 마찰이 있습니다.
|
||||
|
||||
- 발신 → 수신으로 바꿀 때마다 방 ID를 다시 붙여넣어야 함
|
||||
- 카페 Wi‑Fi나 모바일 네트워크에서 잠깐 끊기면 직접 다시 연결해야 함
|
||||
|
||||
작지만, 현실 네트워크에서는 자주 일어납니다. 그리고 “손맛”을 좌우합니다. 그래서 흐름을 정말 부드럽게 만드는 두 가지 개선을 넣었습니다.
|
||||
|
||||
- 수신 측 “캐시 ID 자동 입장”: 조건이 맞으면 자동으로 입력하고 곧바로 입장
|
||||
- 전체 경로 “탄탄한 재연결”: Socket/P2P 어느 쪽이 끊겨도 스스로 재협상/복구
|
||||
|
||||
무엇보다 우리의 아키텍처 레드라인은 그대로입니다. 백엔드는 신호와 방 관리만 담당하고, 파일은 E2E로 암호화된 상태로 브라우저끼리 직접 전송됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 기능 1: 수신 측 캐시 ID 자동 입장
|
||||
|
||||
수신 탭으로 전환했을 때 아래 조건을 만족하면, 마지막으로 저장된 방 ID를 자동으로 채우고 즉시 입장합니다.
|
||||
|
||||
- 현재 수신 탭이고 아직 방에 들어가지 않았음
|
||||
- URL에 `roomId` 파라미터가 없음(주소가 우선, 덮어쓰지 않음)
|
||||
- 입력 칸이 비어 있음(사용자 입력을 덮어쓰지 않음)
|
||||
- localStorage에 캐시 ID가 존재함
|
||||
|
||||
이 로직은 탭 전환 때 트리거됩니다. 조건이 맞으면 입력 칸을 채운 뒤 바로 입장 로직을 호출하여, 붙여넣기/클릭을 한 번 줄입니다.
|
||||
|
||||
- 코드 앵커:
|
||||
- 수신 측 자동 입장 useEffect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
|
||||
- 캐시 유틸(localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
|
||||
|
||||
다음 상황에서는 동작하지 않습니다.
|
||||
|
||||
- 이미 방에 들어가 있음
|
||||
- URL에 `roomId`가 명시됨(공유 링크 등)
|
||||
- 입력 칸에 이미 텍스트가 있고 편집 중임
|
||||
- 캐시 ID가 없음
|
||||
|
||||
---
|
||||
|
||||
## 기능 2: 발신 측 “저장/사용” 버튼(더블 탭으로 업데이트)
|
||||
|
||||
발신 측 방 ID 입력란에 똑똑한 “재사용” 버튼을 추가했습니다. 두 가지 상태를 오갑니다.
|
||||
|
||||
- ID 저장: 입력 길이가 8자 이상이면 활성화. 클릭 시 현재 입력을 캐시 ID로 저장
|
||||
- 캐시 ID 사용: 캐시가 있으면 한 번 탭으로 입력란에 채우고 곧바로 입장. 두 번 탭하면 약 3초간 “ID 저장”으로 잠시 전환되어 캐시를 업데이트할 수 있음
|
||||
|
||||
구현 노트:
|
||||
|
||||
- 단/복 탭은 400ms 윈도우로 판별하며, 컴포넌트 언마운트 시 타이머를 정리
|
||||
- “캐시 ID 사용” 후에는 발신 측이 즉시 입장(추가 “입장” 클릭 불필요)
|
||||
- 8자 미만은 저장 불가로 하여, 짧은 ID 오저장을 방지
|
||||
|
||||
- 코드 앵커:
|
||||
- 단/복 탭과 타이머 정리: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
|
||||
- “캐시 ID 사용” 시 즉시 입장(발신 측): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
|
||||
|
||||
---
|
||||
|
||||
## 재연결: 감지부터 완전 복구까지
|
||||
|
||||
세 가지 지점에서 끊김을 감지하고 재연결을 시도합니다.
|
||||
|
||||
- Socket 끊김: 재연결 후 `socketId`가 바뀌면 자동 재입장
|
||||
- P2P 끊김/실패/종료: 상태를 표시하고 연결 재구성을 시도
|
||||
- `socketId` 변경의 능동 확인: 소켓 복구 시 한 번 더 검증
|
||||
|
||||
- 코드 앵커:
|
||||
- 소켓 연결 후 자동 재입장: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
|
||||
- attemptReconnection 통합 진입점: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
|
||||
- `lastJoinedSocketId` 기록 및 필요 시 `initiator-online` 트리거: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
|
||||
- 발신 측의 `recipient-ready` 처리 및 재협상 시작: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
|
||||
- 수신 측의 `initiator-online` 응답(`recipient-ready` 전송): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
|
||||
- 백엔드 신호 릴레이:
|
||||
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
|
||||
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
|
||||
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
|
||||
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
|
||||
|
||||
### 시퀀스(Mermaid)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Signaling Server
|
||||
participant A as 발신자(initiator)
|
||||
participant B as 수신자(recipient)
|
||||
|
||||
Note over A,B: 네트워크 흔들림으로 Socket/P2P 끊김
|
||||
A->>A: attemptReconnection()
|
||||
A->>S: join(roomId) / (필요 시) initiator-online
|
||||
S-->>B: initiator-online
|
||||
B->>S: recipient-ready(peerId)
|
||||
S-->>A: recipient-ready(peerId)
|
||||
A->>B: offer
|
||||
B->>A: answer
|
||||
A-->>B: ICE candidates
|
||||
B-->>A: ICE candidates
|
||||
Note over A,B: 연결 복구, DataChannel 재수립
|
||||
```
|
||||
|
||||
### 신뢰성 디테일
|
||||
|
||||
- ICE 후보 큐: 원격 설명이 준비되지 않았거나 연결이 종료 단계면 후보를 큐에 담아 두었다가 나중에 한 번에 반영; https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
|
||||
- DataChannel 배압과 청킹: 발신 측 임계치 `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); 네트워크 제어 `maxBuffer≈3MB / lowThreshold≈512KB / 64KB 청크` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
|
||||
- 모바일 Wake Lock: 연결 시 획득, 끊김/실패 시 해제 — 백그라운드 전환으로 인한 중단을 줄임.
|
||||
- 에러 래핑과 재시도: 드물게 발생하는 `sendData failed` 경로를 포착/표면화/재시도(`sendWithBackpressure` 참고).
|
||||
|
||||
### 짧은 ID와 긴 ID 재사용 전략
|
||||
|
||||
- 짧은 ID(4자리)는 “빈 방 + 끊김” 시 백엔드가 TTL을 15분(900s)으로 갱신 — 그 창에서 빠르게 재연결 가능; https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
|
||||
- 기본 방 만료는 24시간; “빈 방 + 끊김”에 한해 일시적으로 15분 보존으로 전환; https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
|
||||
- 긴 ID(UUID 유사)는 세션/디바이스를 넘어 재사용에 유리 — 캐시 ID 버튼과 함께 쓰면 가장 편함.
|
||||
|
||||
---
|
||||
|
||||
## 바로 써보기 (Hands‑on)
|
||||
|
||||
데스크톱 빠른 체험:
|
||||
|
||||
1. 발신 측에서 8자 이상 사용자 지정 ID를 입력하고 “ID 저장” 클릭
|
||||
2. 수신 측으로 전환: 조건이 맞으면 자동 채움 후 즉시 입장
|
||||
3. 끊김 시나리오 시뮬레이션(Wi‑Fi 끄기, 핫스팟 전환, 새로고침 후 복귀) → 자동 재연결 관찰
|
||||
4. 발신 측 “캐시 ID 사용” 더블 탭 → 잠시 “ID 저장”으로 전환 → 새 긴 ID로 갱신
|
||||
|
||||
모바일/열악한 네트워크:
|
||||
|
||||
- 백그라운드 ↔ 포그라운드 전환, Wi‑Fi ↔ 셀룰러 전환
|
||||
- 수신 측 자동 입장과 전송 자동 복구 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 맺음말 & 액션
|
||||
|
||||
연결이 매끄러울수록 P2P의 가치는 커집니다. 캐시 ID 자동 입장과 전 구간 재연결로, PrivyDrop은 현실 네트워크에서 더 튼튼하고 믿을 만해졌습니다.
|
||||
|
||||
유용했다면 GitHub 별을 부탁드립니다(<u>https://github.com/david-bai00/PrivyDrop</u>). 더 많은 사람이 발견할 수 있고, 저희가 계속 다듬어 가는 동력이 됩니다.
|
||||
|
||||
온라인 체험: <u>https://www.privydrop.app</u>. Issue로 사용 소감과 제안을 남겨 주세요. “부드러운 경험”을 더 두텁게 만들어가겠습니다.
|
||||
|
||||
덧붙여, 도메인은 Cloudflare CDN 가속을 사용합니다. 지역 간 속도와 안정성이 크게 향상되어, 더 많은 지역에서 끊김 없이 접속할 수 있습니다.
|
||||
|
||||
더 읽기:
|
||||
|
||||
- [내가 PrivyDrop을 오픈 소스로 공개한 이유](/blog/privydrop-open-source)
|
||||
- [WebRTC가 브라우저 직결 전송을 구현하는 방법](/blog/webRTC-file-transfer)
|
||||
- [중단 후 재개 전송: 대용량 전송의 불안을 넘어](/blog/resumable-transfers)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "一键复连,稳如磐石:PrivyDrop 接收端缓存ID自动连接与断线重连全解析"
|
||||
description: "新增接收端缓存ID自动连接与断线重连,让连接更顺滑:自动入房、单击直连、双击更新缓存、弱网稳复连。"
|
||||
date: "2025-11-25"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/cached-id-reconnect.webp"
|
||||
tags: [新功能, 断线重连, 缓存ID, WebRTC, P2P]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## 引言:为什么我们要做“自动入房”和“断线重连”
|
||||
|
||||
很多第一次使用 PrivyDrop 的用户,都会经历这样两件“小事”:
|
||||
|
||||
- 从发送端切到接收端时,需要再粘贴一次房间 ID;
|
||||
- 在咖啡店 Wi‑Fi 或移动网络下,短暂断网就要手动重连。
|
||||
|
||||
这两件“小事”,在真实世界里却非常高频,也直接决定了“顺不顺手”。为此,我们上线了两项把体验打磨到“顺滑”的改进:
|
||||
|
||||
- 接收端“缓存 ID 自动连接”:满足条件时,自动填充并直接入房;
|
||||
- 全链路“断线重连”:Socket/P2P 任一断开,均自动恢复协商与连接。
|
||||
|
||||
更重要的是,这些改进不改变我们的架构红线:后端只做信令与房间管理,文件数据始终端到端加密,浏览器之间直传。
|
||||
|
||||
---
|
||||
|
||||
## 功能一:接收端缓存 ID 自动连接
|
||||
|
||||
当你切换到“接收”面板,如果满足以下条件,将自动填充上次保存的房间 ID 并直接入房:
|
||||
|
||||
- 当前处于接收面板,且尚未在房间内;
|
||||
- URL 未携带 `roomId` 参数(URL 优先,不做覆盖);
|
||||
- 输入框当前为空(不覆盖用户已有输入);
|
||||
- 本地存在缓存 ID(localStorage)。
|
||||
|
||||
上述逻辑在切换面板时触发,一旦命中,会先填充输入框,再立即调用加入逻辑,减少一次粘贴/点击。
|
||||
|
||||
- 代码锚点:
|
||||
- 前端自动入房 useEffect(接收端):[frontend/components/ClipboardApp.tsx#L151](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151)
|
||||
- 缓存工具(localStorage):[frontend/lib/roomIdCache.ts#L1](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1)
|
||||
|
||||
何时不会触发?
|
||||
|
||||
- 你已在房间中;
|
||||
- URL 显式携带了 `roomId`(例如分享链接带参直达);
|
||||
- 输入框里已有你正在编辑的 ID;
|
||||
- 本地没有缓存 ID。
|
||||
|
||||
---
|
||||
|
||||
## 功能二:发送端“保存/使用缓存 ID”(支持双击更新)
|
||||
|
||||
发送端的房间 ID 输入区新增了一个“复用按钮”,在两种状态间智能切换:
|
||||
|
||||
- 保存 ID:当输入长度 ≥ 8 位时按钮可用,点击后将当前输入保存为缓存 ID;
|
||||
- 使用缓存 ID:若存在缓存 ID,单击即将其写入输入框并立刻入房;双击会“短暂切换”为“保存 ID”,便于你替换更新缓存(约 3 秒后恢复)。
|
||||
|
||||
实现要点:
|
||||
|
||||
- 单/双击通过 400ms 窗口配合计时器实现,并在组件卸载时清理;
|
||||
- “使用缓存 ID”单击后,发送端会立即加入房间(无需再点“加入”);
|
||||
- 输入长度不足 8 位时不会允许保存,避免误存短 ID。
|
||||
|
||||
- 代码锚点:
|
||||
- 单/双击与计时器清理:[frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112)
|
||||
- 使用缓存 ID 后立刻直连(发送端):[frontend/components/ClipboardApp/SendTabPanel.tsx#L193](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193)
|
||||
|
||||
---
|
||||
|
||||
## 断线重连:从检测到恢复的全链路
|
||||
|
||||
我们从三个入口观测“断开”并触发重连:
|
||||
|
||||
- Socket 断开:重连后若 `socketId` 变化,将自动重新入房;
|
||||
- P2P 断开/失败/关闭:标记状态并尝试重建连接;
|
||||
- 主动判断 `socketId` 变化:在 socket 连接恢复时复核一次。
|
||||
|
||||
- 代码锚点:
|
||||
- Socket 连接后自动重入房:[frontend/lib/webrtc_base.ts#L121](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121)
|
||||
- 尝试重连的统一入口:[frontend/lib/webrtc_base.ts#L185](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185)
|
||||
- 记录 `lastJoinedSocketId` 并在需要时触发 `initiator-online`:[frontend/lib/webrtc_base.ts#L460](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460)
|
||||
- 发送端接收 `recipient-ready` 并重启协商:[frontend/lib/webrtc_Initiator.ts#L12](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12)
|
||||
- 接收端响应 `initiator-online` 并发送 `recipient-ready`:[frontend/lib/webrtc_Recipient.ts#L14](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14)
|
||||
- 后端信令转发:
|
||||
- ready:[backend/src/socket/handlers.ts#L63](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63)
|
||||
- initiator-online:[backend/src/socket/handlers.ts#L102](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102)
|
||||
- recipient-ready:[backend/src/socket/handlers.ts#L108](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108)
|
||||
- peer-disconnected:[backend/src/socket/handlers.ts#L119](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119)
|
||||
|
||||
### 时序(Mermaid)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Signaling Server
|
||||
participant A as 发送端(initiator)
|
||||
participant B as 接收端(recipient)
|
||||
|
||||
Note over A,B: 网络波动导致 Socket/P2P 断开
|
||||
A->>A: attemptReconnection()
|
||||
A->>S: join(roomId) / (可能) initiator-online
|
||||
S-->>B: initiator-online
|
||||
B->>S: recipient-ready(peerId)
|
||||
S-->>A: recipient-ready(peerId)
|
||||
A->>B: offer
|
||||
B->>A: answer
|
||||
A-->>B: ICE candidates
|
||||
B-->>A: ICE candidates
|
||||
Note over A,B: 连接恢复,DataChannel 重建
|
||||
```
|
||||
|
||||
### 可靠性细节
|
||||
|
||||
- ICE 候选队列:若远端描述尚未建立或连接处于关闭态,候选会入队,待可用时批量补交;见 [frontend/lib/webrtc_base.ts#L219-L256](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256)。
|
||||
- DataChannel 背压与分片:发送端阈值 `bufferedAmountLowThreshold=256KB`([frontend/lib/webrtc_Initiator.ts#L82](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82));网络发送控制 `maxBuffer≈3MB / lowThreshold≈512KB / 64KB 分片`([frontend/lib/transfer/NetworkTransmitter.ts#L66-L111](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111)、[frontend/lib/transfer/NetworkTransmitter.ts#L160-L210](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210))。
|
||||
- 移动端唤醒锁:连接建立时申请 Wake Lock,断开/失败时释放,降低切后台导致的意外中断;
|
||||
- 错误兜底与重试:小概率 `sendData failed` 会被包装、上报与重试(详见 `sendWithBackpressure` 相关逻辑)。
|
||||
|
||||
### 短 ID 与长 ID 的复用策略
|
||||
|
||||
- 短 ID(4 位)在“空房断开”后,会由后端将房间 TTL 刷新为 15 分钟(900s),窗口期内可直接重连,超时回收;见 [backend/src/socket/handlers.ts#L119-L125](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125)。
|
||||
- 默认房间过期时间为 24 小时,仅在空房断开发生临时 15 分钟保留;见 [backend/src/services/redis.ts#L6](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6)。
|
||||
- 长 ID(如 UUID 级别长度)更适合跨会话、跨设备的持续复用;与缓存 ID 按钮配合使用体验最佳。
|
||||
|
||||
---
|
||||
|
||||
## 如何体验(上手指南)
|
||||
|
||||
桌面端快速体验:
|
||||
|
||||
1. 在发送端输入一个 ≥8 位的自定义 ID,点击“保存 ID”;
|
||||
2. 切换到接收端:若满足条件,将自动填充并直接入房;
|
||||
3. 模拟断线(如:关闭 Wi‑Fi、切到手机热点、刷新页面再返回),观察自动重连;
|
||||
4. 在发送端双击“使用缓存 ID”,短暂切换为“保存 ID”,更新为新的长 ID。
|
||||
|
||||
移动端/弱网场景:
|
||||
|
||||
- 切后台 → 回前台;Wi‑Fi ↔ 蜂窝之间切换;
|
||||
- 关注接收端是否自动入房、传输是否自动恢复。
|
||||
|
||||
---
|
||||
|
||||
## 结语与行动号召
|
||||
|
||||
我们相信,越“顺手”的连接,越能放大 P2P 的价值。接收端缓存 ID 自动连接与断线重连,让 PrivyDrop 在真实网络环境下更加稳健、可依赖。
|
||||
|
||||
如果觉得好用,请到 GitHub 给我们一个 Star(<u>https://github.com/david-bai00/PrivyDrop</u>),便于更多人发现与受益;你的 Star 也会直接影响搜索与推荐权重,是我们持续打磨产品的动力。
|
||||
|
||||
在线体验:<u>https://www.privydrop.app</u>。也欢迎在 Issue 中反馈你的使用体验与改进建议,让我们把“顺滑体验”继续做厚。
|
||||
|
||||
另外,域名已启用 Cloudflare CDN 加速(赛博菩萨),显著提升跨区域的访问速度与稳定性,让更多地区用户打开网站不再卡顿,整体体验更流畅。
|
||||
|
||||
延伸阅读:
|
||||
|
||||
- [我为什么开源了 PrivyDrop](/blog/privydrop-open-source)
|
||||
- [WebRTC 如何实现浏览器直传](/blog/webRTC-file-transfer)
|
||||
- [断点续传:让大文件传输告别焦虑](/blog/resumable-transfers)
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "Warum ich PrivyDrop Open Source gestellt habe: Eine Geschichte über Privatsphäre, WebRTC und Gemeinschaftsaufbau"
|
||||
description: "PrivyDrop ist jetzt Open Source! Dieser Artikel erzählt die Entwicklung von einem persönlichen Bedürfnis zu einem produktionsreifen privaten Dateiübertragungstool, taucht tief in seine Architektur ein und lädt Sie ein, die Zukunft gemeinsam zu gestalten."
|
||||
date: "2025-07-07"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-open-source.jpg"
|
||||
tags: [Open Source, WebRTC, Privatsphäre, Sicherheit, Next.js, Node.js]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Einleitung
|
||||
|
||||
Heute bin ich unglaublich aufgeregt, ankündigen zu können, dass ein persönliches Projekt, in das ich mein Herz und meine Seele gesteckt habe, **PrivyDrop**, nun offiziell Open Source ist!
|
||||
|
||||
[**Jetzt Live testen »**](https://www.privydrop.app/) | [**GitHub Repository »**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
Dieses Projekt begann mit einem sehr einfachen persönlichen Bedürfnis: "Ich möchte nur Dinge sicher und einfach zwischen meinem Telefon und Computer versenden."
|
||||
|
||||
Wenn Sie, wie ich, jemals frustriert waren bei der Suche nach einem Datei-Freigabetool, das keine Registrierung erfordert, keine Geschwindigkeitsbegrenzungen hat und Ihre Privatsphäre wirklich respektiert, dann ist dieser Artikel für Sie. Er wird nicht nur die Geschichte des "Kratzens am eigenen Juckreiz" teilen, sondern Sie auch auf eine vollständige "Hinter-den-Kulissen"-Tour mitnehmen, um PrivyDrops Kernarchitektur und Designphilosophie zu erkunden. Und am wichtigsten ist es eine aufrichtige Einladung, Co-Autor des nächsten Kapitels zu werden.
|
||||
|
||||
## Teil 1: Die Geburt eines Werkzeugs: Von "Ich brauche es" zu "Alle können es nutzen"
|
||||
|
||||
### 1.1 Die Reise eines Entwicklers, seinen eigenen Juckreiz zu kratzen
|
||||
|
||||
Alles begann mit einem kleinen aber hartnäckigen Schmerzpunkt in meinem täglichen Arbeitsablauf.
|
||||
|
||||
Ich muss häufig Dateien, Screenshots oder Text-Schnipsel zwischen meinem Telefon und meinem Laptop schnell senden. Ich habe viele Werkzeuge ausprobiert, aber keine hat meine Anforderungen vollständig erfüllt:
|
||||
|
||||
- Einige Online-P2P-Werkzeuge waren mächtig, konnten aber nur Dateien senden und scheiterten an meinem Bedarf, leichtgewichtigen Text oder Links zu senden.
|
||||
- Einige Online-Zwischenablagen konnten Text bequem synchronisieren, aber ich war zutiefst besorgt darüber, meine Zwischenablageninhalte auf einen unbekannten Server hochzuladen.
|
||||
- Und die Mainstream-Cloud-Speicher- oder Social-Apps erforderten entweder eine Anmeldung oder hatten Größen- und Geschwindigkeitsbegrenzungen, was den gesamten Prozess umständlich und mühsam machte.
|
||||
|
||||
Nachdem ich versagt hatte, ein Werkzeug zu finden, das perfekt meinen drei Kernanforderungen entsprach—**schnell, privat und ohne Konto erforderlich**—entschied ich mich, eines für mich selbst zu bauen.
|
||||
|
||||
### 1.2 Von einem persönlichen Werkzeug zu einem öffentlichen Projekt
|
||||
|
||||
Ursprünglich war PrivyDrop nur ein kleines Werkzeug, um meine eigenen Bedürfnisse zu erfüllen. Aber als ich nach und nach seine Funktionen verbesserte, erkannte ich, dass mein Schmerzpunkt wahrscheinlich ein gemeinsamer war.
|
||||
|
||||
In einer Zeit, in der Daten und Privatsphäre immer wichtiger werden, verdienen wir eine bessere Wahl—ein Werkzeug, das uns nicht zwingt, eine schmerzhafte Kompromisseingenschaft zwischen "Bequemlichkeit" und "Privatsphäre" einzugehen. Diese Idee trieb mich an, PrivyDrop von einem persönlichen Projekt zu einem robusten und zuverlässigen öffentlichen Dienst zu polieren.
|
||||
|
||||
Unsere Kernvision ist einfach, wie ich im README des Projekts schrieb: **Wir glauben, dass jeder die Kontrolle über seine eigenen Daten haben sollte.**
|
||||
|
||||
### 1.3 Warum Open Source? Die einzige Antwort für Vertrauen
|
||||
|
||||
Für ein Werkzeug, das "Privatsphäre und Sicherheit" als Kernwert beansprucht, ist Closed Source ein Widerspruch in sich selbst. Wie können Benutzer Ihren Versprechen vertrauen?
|
||||
|
||||
Daher war Open Source die unvermeidliche Wahl und die einzige Antwort.
|
||||
|
||||
- **Um Vertrauen aufzubauen**: Code ist der beste Beweis. Wir machen all unseren Code öffentlich, um von der Welt geprüft zu werden, und bauen so unbestreitbares Vertrauen auf.
|
||||
- **Die Kraft der Gemeinschaft**: Ich bin mir sehr bewusst, dass die Kraft eines Einzelnen begrenzt ist. Ich glaube, dass die kollektive Weisheit der Gemeinschaft helfen kann, Fehler zu finden, die ich übersehen habe, und Funktionen vorzuschlagen, an die ich nie gedacht habe, und PrivyDrop dabei helfen, weiterzukommen und robuster zu werden.
|
||||
- **Um zurückzugeben und zu lernen**: Ich habe ungemein von der Open-Source-Gemeinschaft profitiert, und jetzt ist es meine Zeit zurückzugeben. Das Projekt Open Source zu stellen, ist sowohl eine Möglichkeit, von talentierten Entwicklern zu lernen, als auch eine Freude des Teilens.
|
||||
|
||||
## Teil 2: Ein tiefer Einblick in die Architektur: Eine "Produktionsreife" Praxis
|
||||
|
||||
PrivyDrop ist nicht nur ein Spielzeugprojekt. In seiner architektonischen Design verfolgten wir Einfachheit, Effizienz und Skalierbarkeit und strebten danach, Produktionsstandards zu erreichen.
|
||||
|
||||
### 2.1 Das große Ganze: Ein einfaches und effizientes System
|
||||
|
||||
Unser Kern-Designprinzip ist: **ein leichtgewichtiger Backend, ein intelligentes Frontend**. Das Backend agiert nur als "Verkehrspolizist" (für Signalisierung), während das Frontend alle "schweren Arbeiten" (Dateiverarbeitung und -übertragung) übernimmt.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Benutzer A (Sender)"
|
||||
A[Frontend App]
|
||||
end
|
||||
subgraph "Benutzer B (Empfänger)"
|
||||
B[Frontend App]
|
||||
end
|
||||
subgraph "Cloud"
|
||||
C(Signalisierungsserver - Node.js)
|
||||
D(Zustandsspeicher - Redis)
|
||||
end
|
||||
|
||||
A -- "1. Anfrage zum Erstellen/Beitreten von Raum" --> C
|
||||
B -- "2. Anfrage zum Beitreten zum gleichen Raum" --> C
|
||||
C -- "3. WebRTC-Signale austauschen (SDP/ICE)" --> A
|
||||
C -- "4. WebRTC-Signale austauschen (SDP/ICE)" --> B
|
||||
A <-.-> B;
|
||||
C <--> D
|
||||
A <-. "5. P2P-Direktverbindung herstellen" .-> B
|
||||
A -- "6. Dateien/Text direkt übertragen" --> B
|
||||
|
||||
style A fill:#D5E8D4,stroke:#82B366
|
||||
style B fill:#D5E8D4,stroke:#82B366
|
||||
```
|
||||
|
||||
### 2.2 Frontend-Architektur: Von der Trennung von Belangen zur logischen Kohäsion
|
||||
|
||||
Das Frontend ist mit Next.js 14 gebaut, und unsere Kern-Designphilosophie ist **benutzerdefinierte Hooks als Herz unserer Geschäftslogik zu verwenden**.
|
||||
|
||||
Sie könnten fragen, warum nicht Redux oder Zustand? Für PrivyDrop ist der meiste Zustand eng gekoppelt mit spezifischer, hochkohärenter Geschäftslogik. Wir kapselten diese Logik und den Zustand in eine Reihe von benutzerdefinierten Hooks (wie `useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler`), was mehrere klare Vorteile brachte:
|
||||
|
||||
- **Logische Kohäsion**: Alle Zustände und Methoden im Zusammenhang mit der WebRTC-Verbindung sind in `useWebRTCConnection`, was es extrem einfach zu warten macht.
|
||||
- **Reine Komponenten**: React-Komponenten werden von komplexer Geschäftslogik befreit und kehren zu ihrer wesentlichen Rolle des UI-Renderings zurück.
|
||||
- **Klare Schichtung**: Dies schafft einen klaren Datenfluss und Abhängigkeitsbeziehung von `app` (Routing) -> `components` (UI) -> `hooks` (Logik) -> `lib` (Low-Level-Fähigkeiten), was die Wartbarkeit des Code-Basis erheblich verbessert.
|
||||
|
||||
### 2.3 Backend-Architektur: Die Kunst der Zustandslosigkeit und Effizienz
|
||||
|
||||
Das Backend, basierend auf Node.js und Express, folgt in seinem Design streng dem **zustandslosen (Stateless)** Prinzip.
|
||||
|
||||
Der Server selbst hält keinen Zustand im Zusammenhang mit Räumen oder Benutzern. Der gesamte Zustand wird an **Redis** delegiert. Dies ermöglicht es der Backend-Anwendung, leicht horizontal skaliert zu werden.
|
||||
|
||||
Wir nutzten auch clever verschiedene Redis-Datenstrukturen, um Geschäftsanforderungen zu erfüllen:
|
||||
|
||||
- **Hash**: Zum Speichern von Raum-Metadaten.
|
||||
- **Set**: Zum Speichern der `socketId` aller Mitglieder in einem Raum, wodurch Eindeutigkeit sichergestellt wird.
|
||||
- **String**: Um eine `socketId` rückwärts auf ihre `roomId` abzubilden, was eine schnelle Bereinigung bei Benutzertrennung erleichtert.
|
||||
- **Sorted Set**: Zur Implementierung von IP-basierter Ratenbegrenzung, was schädliche Anriffe effektiv verhindert.
|
||||
|
||||
Alle Schlüssel sind mit einer angemessenen TTL (Time To Live) eingestellt, was eine automatische Ressourcenbereinigung sicherstellt und dem System ermöglicht, langfristig stabil zu laufen.
|
||||
|
||||
### 2.4 "Produktionsreife" Überlegungen: Von Bereitstellung bis Sicherheit
|
||||
|
||||
Wir bieten einen vollständigen Produktionsbereitstellungsplan, einschließlich:
|
||||
|
||||
- Verwendung von **Nginx** als Reverse-Proxy und für SSL-Terminierung.
|
||||
- Verwendung von **PM2** für Node.js-Prozessmanagement.
|
||||
- Verwendung von **Certbot** für automatische SSL-Zertifikatserwerbung und -erneuerung.
|
||||
- Eine umfassende Anleitung zur Einrichtung eines **TURN/STUN**-Servers für Szenarien, die das Durchqueren komplexer NATs erfordern.
|
||||
|
||||
All dies zeigt, dass PrivyDrop ein ernstes Projekt ist, dem vertraut und das in einer Produktionsumgebung bereitgestellt werden kann.
|
||||
|
||||
## Teil 3: Mehr als Code: Eine Einladung, die Zukunft zu gestalten
|
||||
|
||||
Open Source ist nur der Anfang. Wir haben eine aufregende Zukunft für PrivyDrop geplant, und jetzt möchten wir Sie einladen, sich uns anzuschließen.
|
||||
|
||||
### 3.1 Projekt-Roadmap
|
||||
|
||||
Wir haben eine öffentliche [<u>**Projekt-Roadmap**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md), die unsere zukünftigen Prioritäten umreißt. Wir planen, in Zukunft einige stark nachgefragte Funktionen hinzuzufügen, wie zum Beispiel:
|
||||
|
||||
- **Wiederaufnehmbare Übertragungen**: Um sehr große Dateien und instabile Netzwerkbedingungen zu bewältigen.
|
||||
- **E2E-verschlüsselter Gruppenchat**: Um sichere P2P-Kommunikation auf Multi-Benutzer-Text-Chats zu erweitern.
|
||||
- Andere unbestimmte Funktionen.
|
||||
|
||||
### 3.2 Wie kann man beitragen?
|
||||
|
||||
Wir begrüßen Beiträge jeglicher Form! Egal wer Sie sind, es gibt immer einen Weg zu helfen, PrivyDrop besser zu machen. Bitte lesen Sie unsere [<u>**Beitragsrichtlinien**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md), um Ihre Reise zu beginnen.
|
||||
|
||||
- **Für Benutzer**: Verwenden Sie das Produkt, melden Sie Fehler und schlagen Sie Funktionen über [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues) vor.
|
||||
- **Für Entwickler**: Übernehmen Sie einen Fehler, implementieren Sie eine neue Funktion oder refaktorisieren Sie ein Stück bestehenden Codes.
|
||||
- **Für Dokumentatoren/Übersetzer**: Helfen Sie uns, die Dokumentation zu verbessern oder PrivyDrop in mehr Sprachen zu übersetzen.
|
||||
|
||||
### 3.3 Ein starker Aufruf zum Handeln
|
||||
|
||||
- **Für Benutzer**: Erleben Sie jetzt die ultimative Privatsphäre und Bequemlichkeit mit PrivyDrop!
|
||||
[**➡️ Jetzt Live testen**](https://www.privydrop.app/)
|
||||
|
||||
- **Für Entwickler**: Wenn PrivyDrops Philosophie oder Technologie Sie begeistert, geben Sie unserem GitHub-Repository bitte einen Stern! Es ist die größte Anerkennung und Ermutigung für uns.
|
||||
[**⭐️ Geben Sie uns einen Stern auf GitHub**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
- **Für alle**: Treten Sie unseren Gemeinschaftsdiskussionen bei und lassen Sie uns Ihre Stimme hören!
|
||||
|
||||
## Fazit
|
||||
|
||||
Vielen Dank erneut, dass Sie sich Zeit genommen haben, diese Geschichte zu lesen.
|
||||
|
||||
Die Geschichte von PrivyDrop begann mit dem Bedürfnis einer Person, und ich freue mich darauf, dass ihre Zukunft von einer Gemeinschaft geschrieben wird.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "Por qué Abierto PrivyDrop: Una Historia de Privacidad, WebRTC y Construcción Comunitaria"
|
||||
description: "¡PrivyDrop ahora es código abierto! Este artículo narra la evolución desde una necesidad personal hasta una herramienta de transferencia de archivos privada de nivel producción, profundiza en su arquitectura y te invita a construir el futuro juntos."
|
||||
date: "2025-07-07"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-open-source.jpg"
|
||||
tags: [Código Abierto, WebRTC, Privacidad, Seguridad, Next.js, Node.js]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introducción
|
||||
|
||||
Hoy, estoy increíblemente emocionado de anunciar que un proyecto personal en el que he volcado mi corazón y alma, **PrivyDrop**, ahora es oficialmente código abierto.
|
||||
|
||||
[**Pruébalo en Vivo »**](https://www.privydrop.app/) | [**Repositorio GitHub »**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
Este proyecto comenzó con una necesidad personal muy simple: "Solo quiero enviar cosas entre mi teléfono y mi computadora, de forma segura y fácil."
|
||||
|
||||
Si tú, como yo, alguna vez te has frustrado buscando una herramienta para compartir archivos que no requiera registro, no tenga límites de velocidad y realmente respete tu privacidad, entonces este artículo es para ti. No solo compartirá la historia de "rascarme mi propia picazón", sino que también te llevará en un completo "detrás de cámaras" para explorar la arquitectura central y la filosofía de diseño de PrivyDrop. Y lo más importante, es una sincera invitación para que te conviertas en co-autor de su próximo capítulo.
|
||||
|
||||
## Parte 1: El Nacimiento de una Herramienta: Desde "Lo Necesito" hasta "Todos Pueden Usarlo"
|
||||
|
||||
### 1.1 El Viaje de un Desarrollador para Rascarse su Propia Picazón
|
||||
|
||||
Todo comenzó con un pequeño pero persistente punto de dolor en mi flujo de trabajo diario.
|
||||
|
||||
Frecuentemente necesito enviar rápidamente archivos, capturas de pantalla o fragmentos de texto entre mi teléfono y mi laptop. Probé muchas herramientas, pero ninguna cumplió completamente con mis requisitos:
|
||||
|
||||
- Algunas herramientas P2P en línea eran poderosas pero solo podían enviar archivos, fallando en mi necesidad de enviar texto ligero o enlaces.
|
||||
- Algunos portapapeles en línea podían sincronizar texto convenientemente, pero estaba profundamente preocupado por subir el contenido de mi portapapeles a un servidor desconocido.
|
||||
- Y las aplicaciones主流 de almacenamiento en la nube o sociales requerían iniciar sesión o tenían límites de tamaño y velocidad, haciendo que todo el proceso se sintiera torpe y engorroso.
|
||||
|
||||
Después de fallar en encontrar una herramienta que coincidiera perfectamente con mis tres requisitos centrales—**rápido, privado y sin necesidad de cuenta**—decidí construir una para mí mismo.
|
||||
|
||||
### 1.2 Desde una Utilidad Personal a un Proyecto Público
|
||||
|
||||
Inicialmente, PrivyDrop era solo una pequeña utilidad para satisfacer mis propias necesidades. Pero a medida que mejoré gradualmente sus características, me di cuenta de que mi punto de dolor probablemente era común.
|
||||
|
||||
En una era donde los datos y la privacidad son cada vez más importantes, merecemos una mejor opción—una herramienta que no nos obligue a hacer una dolorosa compensación entre "conveniencia" y "privacidad". Esta idea me impulsó a pulir PrivyDrop desde un proyecto personal hasta un servicio público robusto y confiable.
|
||||
|
||||
Nuestra visión central es simple, como escribí en el README del proyecto: **Creemos que todos deberían tener control sobre sus propios datos.**
|
||||
|
||||
### 1.3 ¿Por qué Código Abierto? La Única Respuesta para la Confianza
|
||||
|
||||
Para una herramienta que reclama "privacidad y seguridad" como su valor central, ser de código cerrado es una contradicción en sí misma. ¿Cómo pueden los usuarios confiar en tus promesas?
|
||||
|
||||
Por lo tanto, el código abierto fue la elección inevitable y la única respuesta.
|
||||
|
||||
- **Para Construir Confianza**: El código es la mejor prueba. Estamos haciendo público todo nuestro código para ser escrutado por el mundo, construyendo así una confianza innegable.
|
||||
- **El Poder de la Comunidad**: Soy muy consciente de que el poder de un individuo es limitado. Creo que la sabiduría colectiva de la comunidad puede ayudar a encontrar defectos que he pasado por alto y sugerir características que nunca he pensado, ayudando a PrivyDrop a ir más lejos y volverse más robusto.
|
||||
- **Para Devolver y Aprender**: He beneficiado inmensamente de la comunidad de código abierto, y ahora es mi momento de devolver. Abrir el código del proyecto es tanto una forma de aprender de desarrolladores talentosos como una alegría de compartir.
|
||||
|
||||
## Parte 2: Una Inmersión Profunda en la Arquitectura: Una Práctica de "Nivel Producción"
|
||||
|
||||
PrivyDrop no es solo un proyecto de juguete. En su diseño arquitectónico, buscamos simplicidad, eficiencia y escalabilidad, esforzándonos por cumplir con los estándares de nivel producción.
|
||||
|
||||
### 2.1 El Panorama General: Un Sistema Simple y Eficiente
|
||||
|
||||
Nuestro principio de diseño central es: **un backend ligero, un frontend inteligente**. El backend solo actúa como "agente de tráfico" (para señalización), mientras que el frontend maneja todo el "trabajo pesado" (procesamiento y transferencia de archivos).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Usuario A (Remitente)"
|
||||
A[Aplicación Frontend]
|
||||
end
|
||||
subgraph "Usuario B (Receptor)"
|
||||
B[Aplicación Frontend]
|
||||
end
|
||||
subgraph "Nube"
|
||||
C(Servidor de Señalización - Node.js)
|
||||
D(Almacenamiento de Estado - Redis)
|
||||
end
|
||||
|
||||
A -- "1. Solicitar crear/unirse a sala" --> C
|
||||
B -- "2. Solicitar unirse a misma sala" --> C
|
||||
C -- "3. Intercambiar señales WebRTC (SDP/ICE)" --> A
|
||||
C -- "4. Intercambiar señales WebRTC (SDP/ICE)" --> B
|
||||
A <-.-> B;
|
||||
C <--> D
|
||||
A <-. "5. Establecer conexión P2P directa" .-> B
|
||||
A -- "6. Transferir archivos/texto directamente" --> B
|
||||
|
||||
style A fill:#D5E8D4,stroke:#82B366
|
||||
style B fill:#D5E8D4,stroke:#82B366
|
||||
```
|
||||
|
||||
### 2.2 Arquitectura Frontend: Desde la Separación de Preocupaciones hasta la Cohesión Lógica
|
||||
|
||||
El frontend está construido con Next.js 14, y nuestra filosofía de diseño central es **usar Hooks personalizados como el corazón de nuestra lógica de negocio**.
|
||||
|
||||
Podrías preguntar, ¿por qué no Redux o Zustand? Para PrivyDrop, la mayor parte del estado está estrechamente acoplado con lógica de negocio específica y altamente cohesiva. Encapsulamos esta lógica y estado en una serie de Hooks personalizados (como `useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler`), lo que trajo varios beneficios claros:
|
||||
|
||||
- **Cohesión Lógica**: Todo el estado y métodos relacionados con la conexión WebRTC están en `useWebRTCConnection`, haciéndolo extremadamente fácil de mantener.
|
||||
- **Componentes Puros**: Los componentes de React se liberan de la compleja lógica de negocio, regresando a su rol esencial de renderizar UI.
|
||||
- **Capas Claras**: Esto crea un claro flujo de datos y relación de dependencia desde `app` (enrutamiento) -> `components` (UI) -> `hooks` (lógica) -> `lib` (capacidades de bajo nivel), mejorando enormemente la capacidad de mantenimiento del código base.
|
||||
|
||||
### 2.3 Arquitectura Backend: El Arte de la Sin Estado y Eficiencia
|
||||
|
||||
El backend, basado en Node.js y Express, sigue estrictamente el principio **sin estado (stateless)** en su diseño.
|
||||
|
||||
El servidor en sí no mantiene ningún estado relacionado con salas o usuarios. Todo el estado es delegado a **Redis**. Esto permite que la aplicación backend sea escalada horizontalmente con facilidad.
|
||||
|
||||
También utilizamos clevermente diferentes estructuras de datos de Redis para satisfacer las necesidades del negocio:
|
||||
|
||||
- **Hash**: Para almacenar metadatos de sala.
|
||||
- **Set**: Para almacenar el `socketId` de todos los miembros en una sala, asegurando unicidad.
|
||||
- **String**: Para mapear inversamente un `socketId` a su `roomId`, facilitando una limpieza rápida cuando un usuario se desconecta.
|
||||
- **Sorted Set**: Para implementar limitación de velocidad basada en IP, previniendo efectivamente ataques maliciosos.
|
||||
|
||||
Todas las claves están configuradas con un TTL (Time To Live) razonable, asegurando la limpieza automática de recursos y permitiendo que el sistema funcione de manera estable a largo plazo.
|
||||
|
||||
### 2.4 Consideraciones de "Nivel Producción": Desde Despliegue hasta Seguridad
|
||||
|
||||
Proporcionamos un plan completo de despliegue en producción, incluyendo:
|
||||
|
||||
- Usar **Nginx** como proxy inverso y para terminación SSL.
|
||||
- Usar **PM2** para gestión de procesos Node.js.
|
||||
- Usar **Certbot** para adquisición y renovación automática de certificados SSL.
|
||||
- Una guía comprensiva para configurar un servidor **TURN/STUN** para escenarios que requieren atravesar NATs complejos.
|
||||
|
||||
Todo esto demuestra que PrivyDrop es un proyecto serio que puede ser confiado y desplegado a un entorno de producción.
|
||||
|
||||
## Parte 3: Más Allá del Código: Una Invitación a Construir el Futuro
|
||||
|
||||
Abrir el código es solo el comienzo. Tenemos un futuro emocionante planeado para PrivyDrop, y ahora, queremos invitarte a unirte a nosotros.
|
||||
|
||||
### 3.1 Hoja de Ruta del Proyecto
|
||||
|
||||
Tenemos una [<u>**Hoja de Ruta del Proyecto**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md) pública que describe nuestras futuras prioridades. Planeamos agregar algunas características muy solicitadas en el futuro, tales como:
|
||||
|
||||
- **Transferencias Reanudables**: Para manejar archivos muy grandes y condiciones de red inestables.
|
||||
- **Chat Grupal Cifrado E2E**: Para extender la comunicación P2P segura a chats de texto multi-usuario.
|
||||
- Otras características por determinar.
|
||||
|
||||
### 3.2 ¿Cómo Contribuir?
|
||||
|
||||
¡Bienvenimos contribuciones de todas las formas! No importa quién seas, siempre hay una manera de ayudar a hacer PrivyDrop mejor. Por favor lee nuestras [<u>**Guías de Contribución**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md) para comenzar tu viaje.
|
||||
|
||||
- **Para Usuarios**: Usa el producto, reporta bugs y sugiere características a través de [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues).
|
||||
- **Para Desarrolladores**: Reclama un bug, implementa una nueva característica, o refactoriza una pieza de código existente.
|
||||
- **Para Documentadores/Traductores**: Ayúdanos a mejorar la documentación o traduce PrivyDrop a más idiomas.
|
||||
|
||||
### 3.3 Una Fuerte Llamada a la Acción
|
||||
|
||||
- **Para Usuarios**: ¡Experimenta la máxima privacidad y conveniencia con PrivyDrop ahora!
|
||||
[**➡️ Pruébalo en Vivo**](https://www.privydrop.app/)
|
||||
|
||||
- **Para Desarrolladores**: Si la filosofía o tecnología de PrivyDrop te emociona, por favor da una Estrella a nuestro repositorio GitHub. ¡Es el mayor reconocimiento y aliento para nosotros!
|
||||
[**⭐️ Danos Estrella en GitHub**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
- **Para Todos**: ¡Únete a nuestras discusiones comunitarias y deja que escuchemos tu voz!
|
||||
|
||||
## Conclusión
|
||||
|
||||
Gracias nuevamente por tomar el tiempo de leer esta historia.
|
||||
|
||||
La historia de PrivyDrop comenzó con la necesidad de una persona, y espero que su futuro sea escrito por una comunidad.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "Pourquoi j'ai mis PrivyDrop en Open Source : Une histoire de confidentialité, WebRTC et construction communautaire"
|
||||
description: "PrivyDrop est maintenant en open source ! Cet article raconte son évolution d'un besoin personnel à un outil de transfert de fichiers privé de qualité production, plonge en profondeur dans son architecture et vous invite à construire l'avenir ensemble."
|
||||
date: "2025-07-07"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-open-source.jpg"
|
||||
tags: [Open Source, WebRTC, Confidentialité, Sécurité, Next.js, Node.js]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
Aujourd'hui, je suis incroyablement excité d'annoncer qu'un projet personnel auquel j'ai consacré mon cœur et mon âme, **PrivyDrop**, est maintenant officiellement open source !
|
||||
|
||||
[**Essayez-le en Direct »**](https://www.privydrop.app/) | [**Dépôt GitHub »**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
Ce projet a commencé avec un besoin personnel très simple : "Je veux juste envoyer des choses entre mon téléphone et mon ordinateur, de manière sécurisée et facile."
|
||||
|
||||
Si vous, comme moi, avez déjà été frustré en cherchant un outil de partage de fichiers qui ne nécessite aucune inscription, n'a pas de limites de vitesse et respecte vraiment votre confidentialité, alors cet article est pour vous. Il partagera non seulement l'histoire de "me gratter là où ça me démange", mais vous emmènera également dans une visite complète "coulisses" pour explorer l'architecture centrale et la philosophie de conception de PrivyDrop. Et plus important encore, c'est une invitation sincère à devenir co-auteur de son prochain chapitre.
|
||||
|
||||
## Partie 1 : La naissance d'un outil : De "J'en ai besoin" à "Tout le monde peut l'utiliser"
|
||||
|
||||
### 1.1 Le parcours d'un développeur pour se gratter là où ça lui démange
|
||||
|
||||
Tout a commencé par un petit point de douleur persistant dans mon flux de travail quotidien.
|
||||
|
||||
J'ai fréquemment besoin d'envoyer rapidement des fichiers, des captures d'écran ou des extraits de texte entre mon téléphone et mon ordinateur portable. J'ai essayé de nombreux outils, mais aucun n'a complètement satisfait mes exigences :
|
||||
|
||||
- Certains outils P2P en ligne étaient puissants mais ne pouvaient envoyer que des fichiers, échouant à mon besoin d'envoyer du texte léger ou des liens.
|
||||
- Certains presse-papiers en ligne pouvaient synchroniser du texte commodément, mais j'étais profondément préoccupé par l'upload de mon contenu de presse-papiers sur un serveur inconnu.
|
||||
- Et les applications主流 de stockage cloud ou sociales nécessitaient soit de se connecter, soit avaient des limites de taille et de vitesse, rendant tout le processus lourd et fastidieux.
|
||||
|
||||
Après avoir échoué à trouver un outil qui correspondait parfaitement à mes trois exigences centrales—**rapide, privé et sans compte nécessaire**—j'ai décidé d'en construire un pour moi-même.
|
||||
|
||||
### 1.2 D'un utilitaire personnel à un projet public
|
||||
|
||||
Initialement, PrivyDrop n'était qu'un petit utilitaire pour répondre à mes propres besoins. Mais à mesure que j'améliorais progressivement ses fonctionnalités, j'ai réalisé que mon point de douleur était probablement courant.
|
||||
|
||||
À une époque où les données et la confidentialité sont de plus en plus importantes, nous méritons un meilleur choix—un outil qui ne nous force pas à faire un compromis douloureux entre "commodité" et "confidentialité". Cette idée m'a poussé à polir PrivyDrop d'un projet personnel à un service public robuste et fiable.
|
||||
|
||||
Notre vision centrale est simple, comme je l'ai écrite dans le README du projet : **Nous croyons que tout le monde devrait avoir le contrôle sur ses propres données.**
|
||||
|
||||
### 1.3 Pourquoi l'Open Source ? La seule réponse pour la confiance
|
||||
|
||||
Pour un outil qui prétend "confidentialité et sécurité" comme valeur centrale, être en code fermé est une contradiction en soi. Comment les utilisateurs peuvent-ils faire confiance à vos promesses ?
|
||||
|
||||
Par conséquent, l'open source était le choix inévitable et la seule réponse.
|
||||
|
||||
- **Pour construire la confiance** : Le code est la meilleure preuve. Nous rendons tout notre code public pour être examiné par le monde, construisant ainsi une confiance incontestable.
|
||||
- **Le pouvoir de la communauté** : Je suis bien conscient que le pouvoir d'un individu est limité. Je crois que la sagesse collective de la communauté peut aider à trouver des défauts que j'ai manqués et suggérer des fonctionnalités auxquelles je n'ai jamais pensé, aidant PrivyDrop à aller plus loin et à devenir plus robuste.
|
||||
- **Pour donner en retour et apprendre** : J'ai énormément bénéficié de la communauté open source, et maintenant c'est mon tour de donner en retour. Mettre le projet en open source est à la fois une façon d'apprendre de développeurs talentueux et une joie de partager.
|
||||
|
||||
## Partie 2 : Une plongée en profondeur dans l'architecture : Une pratique "de qualité production"
|
||||
|
||||
PrivyDrop n'est pas juste un projet jouet. Dans sa conception architecturale, nous avons poursuivi la simplicité, l'efficacité et la scalabilité, s'efforçant d'atteindre les standards de qualité production.
|
||||
|
||||
### 2.1 La vue d'ensemble : Un système simple et efficace
|
||||
|
||||
Notre principe de conception central est : **un backend léger, un frontend intelligent**. Le backend n'agit que comme "agent de circulation" (pour la signalisation), tandis que le frontend gère tout le "travail lourd" (traitement et transfert de fichiers).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Utilisateur A (Expéditeur)"
|
||||
A[Application Frontend]
|
||||
end
|
||||
subgraph "Utilisateur B (Destinataire)"
|
||||
B[Application Frontend]
|
||||
end
|
||||
subgraph "Cloud"
|
||||
C(Serveur de Signalisation - Node.js)
|
||||
D(Stockage d'État - Redis)
|
||||
end
|
||||
|
||||
A -- "1. Demander à créer/rejoindre une salle" --> C
|
||||
B -- "2. Demander à rejoindre la même salle" --> C
|
||||
C -- "3. Échanger des signaux WebRTC (SDP/ICE)" --> A
|
||||
C -- "4. Échanger des signaux WebRTC (SDP/ICE)" --> B
|
||||
A <-.-> B;
|
||||
C <--> D
|
||||
A <-. "5. Établir une connexion P2P directe" .-> B
|
||||
A -- "6. Transférer fichiers/texte directement" --> B
|
||||
|
||||
style A fill:#D5E8D4,stroke:#82B366
|
||||
style B fill:#D5E8D4,stroke:#82B366
|
||||
```
|
||||
|
||||
### 2.2 Architecture Frontend : De la séparation des préoccupations à la cohésion logique
|
||||
|
||||
Le frontend est construit avec Next.js 14, et notre philosophie de conception centrale est **d'utiliser des Hooks personnalisés comme cœur de notre logique métier**.
|
||||
|
||||
Vous pourriez demander, pourquoi pas Redux ou Zustand ? Pour PrivyDrop, la plupart de l'état est étroitement couplé avec une logique métier spécifique et hautement cohésive. Nous avons encapsulé cette logique et cet état dans une série de Hooks personnalisés (comme `useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler`), ce qui a apporté plusieurs avantages clairs :
|
||||
|
||||
- **Cohésion logique** : Tout l'état et les méthodes liés à la connexion WebRTC sont dans `useWebRTCConnection`, le rendant extrêmement facile à maintenir.
|
||||
- **Composants purs** : Les composants React sont libérés de la logique métier complexe, retournant à leur rôle essentiel de rendu d'interface utilisateur.
|
||||
- **Clarté des couches** : Cela crée un flux de données clair et une relation de dépendance de `app` (routage) -> `components` (interface utilisateur) -> `hooks` (logique) -> `lib` (capacités de bas niveau), améliorant grandement la maintenabilité de la base de code.
|
||||
|
||||
### 2.3 Architecture Backend : L'art de l'étatlessness et de l'efficacité
|
||||
|
||||
Le backend, basé sur Node.js et Express, suit strictement le principe **stateless (sans état)** dans sa conception.
|
||||
|
||||
Le serveur lui-même ne détient aucun état lié aux salles ou aux utilisateurs. Tout l'état est délégué à **Redis**. Cela permet à l'application backend d'être facilement mise à l'échelle horizontalement.
|
||||
|
||||
Nous avons également utilisé intelligemment différentes structures de données Redis pour répondre aux besoins métier :
|
||||
|
||||
- **Hash** : Pour stocker les métadonnées de salle.
|
||||
- **Set** : Pour stocker le `socketId` de tous les membres dans une salle, assurant l'unicité.
|
||||
- **String** : Pour mapper inversement un `socketId` à sa `roomId`, facilitant un nettoyage rapide lorsqu'un utilisateur se déconnecte.
|
||||
- **Sorted Set** : Pour implémenter la limitation de débit basée sur IP, prévenant efficacement les attaques malveillantes.
|
||||
|
||||
Toutes les clés sont définies avec un TTL (Time To Live) raisonnable, assurant le nettoyage automatique des ressources et permettant au système de fonctionner de manière stable à long terme.
|
||||
|
||||
### 2.4 Considérations "de qualité production" : Du déploiement à la sécurité
|
||||
|
||||
Nous fournissons un plan complet de déploiement en production, incluant :
|
||||
|
||||
- Utilisation de **Nginx** comme proxy inverse et pour terminaison SSL.
|
||||
- Utilisation de **PM2** pour la gestion des processus Node.js.
|
||||
- Utilisation de **Certbot** pour l'acquisition et le renouvellement automatiques des certificats SSL.
|
||||
- Un guide complet pour configurer un serveur **TURN/STUN** pour les scénarios nécessitant le traversal de NATs complexes.
|
||||
|
||||
Tout cela démontre que PrivyDrop est un projet sérieux qui peut être approuvé et déployé dans un environnement de production.
|
||||
|
||||
## Partie 3 : Plus que du code : Une invitation à construire l'avenir
|
||||
|
||||
L'open source n'est que le début. Nous avons planifié un avenir passionnant pour PrivyDrop, et maintenant, nous voulons vous inviter à nous rejoindre.
|
||||
|
||||
### 3.1 Feuille de route du projet
|
||||
|
||||
Nous avons une [<u>**Feuille de route du projet**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md) publique qui décrit nos priorités futures. Nous prévoyons d'ajouter certaines fonctionnalités très demandées à l'avenir, telles que :
|
||||
|
||||
- **Transferts repriseables** : Pour gérer les très gros fichiers et les conditions réseau instables.
|
||||
- **Chat de groupe chiffré E2E** : Pour étendre la communication P2P sécurisée aux chats texte multi-utilisateurs.
|
||||
- Autres fonctionnalités à déterminer.
|
||||
|
||||
### 3.2 Comment contribuer ?
|
||||
|
||||
Nous accueillons les contributions de toutes formes ! Peu importe qui vous êtes, il y a toujours un moyen d'aider à rendre PrivyDrop meilleur. Veuillez lire nos [<u>**Directives de contribution**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md) pour commencer votre voyage.
|
||||
|
||||
- **Pour les utilisateurs** : Utilisez le produit, signalez des bugs et suggérez des fonctionnalités via les [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues).
|
||||
- **Pour les développeurs** : Réclamez un bug, implémentez une nouvelle fonctionnalité, ou refactorez un morceau de code existant.
|
||||
- **Pour les documentaristes/traducteurs** : Aidez-nous à améliorer la documentation ou traduisez PrivyDrop en plus de langues.
|
||||
|
||||
### 3.3 Un fort appel à l'action
|
||||
|
||||
- **Pour les utilisateurs** : Expérimentez maintenant la confidentialité et la commodité ultimes avec PrivyDrop !
|
||||
[**➡️ Essayez-le en Direct**](https://www.privydrop.app/)
|
||||
|
||||
- **Pour les développeurs** : Si la philosophie ou la technologie de PrivyDrop vous excite, veuillez donner une Étoile à notre dépôt GitHub ! C'est la plus grande reconnaissance et encouragement pour nous.
|
||||
[**⭐️ Donnez-nous une Étoile sur GitHub**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
- **Pour tous** : Rejoignez nos discussions communautaires et laissez-nous entendre votre voix !
|
||||
|
||||
## Conclusion
|
||||
|
||||
Merci encore d'avoir pris le temps de lire cette histoire.
|
||||
|
||||
L'histoire de PrivyDrop a commencé avec le besoin d'une personne, et j'attends avec impatience que son avenir soit écrit par une communauté.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "PrivyDropをオープンソースにした理由:プライバシー、WebRTC、そしてコミュニティ育成の物語"
|
||||
description: "PrivyDropが正式にオープンソースになりました!この記事では、個人的なニーズから生産レベルのプライベートファイル転送ツールへと進化した道のりを語り、そのアーキテクチャを深く掘り下げ、未来を共に築くための招待状です。"
|
||||
date: "2025-07-07"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-open-source.jpg"
|
||||
tags: [オープンソース, WebRTC, プライバシー, セキュリティ, Next.js, Node.js]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## はじめに
|
||||
|
||||
本日、心を込めて開発してきた個人プロジェクトである**PrivyDrop**が、正式にオープンソースとなりましたことを、心より誇りに思います!
|
||||
|
||||
[**今すぐ体験 »**](https://www.privydrop.app/) | [**GitHubリポジトリ »**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
このプロジェクトは、非常にシンプルな個人的なニーズから始まりました:「スマートフォンとコンピューターの間で、安全かつ簡単にファイルを送信したいだけ。」
|
||||
|
||||
もしあなたも私のように、登録不要、速度制限なし、真にプライバシーを尊重するファイル共有ツールを探して困った経験があるなら、この記事はあなたのためのものです。自分自身の「痒いところに手が届く」ツール開発の物語を共有するだけでなく、PrivyDropの中核的なアーキテクチャと設計哲学を探求する完全な「舞台裏」ツアーをご案内します。そして最も重要なこととして、あなたをこの物語の次の章の共同執筆者として招待します。
|
||||
|
||||
## 第1部:ツールの誕生:「私が必要」から「皆が使える」へ
|
||||
|
||||
### 1.1 開発者自身の痒いところに手を届ける物語
|
||||
|
||||
すべては、私の日々のワークフローにおける小さくも持続的な痛みから始まりました。
|
||||
|
||||
私は頻繁にスマートフォンとノートパソコンの間でファイル、スクリーンショット、テキストの断片を素早く送信する必要がありました。多くのツールを試しましたが、どれも私の要求を完全には満たしていませんでした:
|
||||
|
||||
- 一部のオンラインP2Pツールは強力でしたが、ファイルしか送信できず、軽量なテキストやリンクを送信するニーズには応えられませんでした。
|
||||
- 一部のオンラインクリップボードはテキストを便利に同期できましたが、クリップボードの内容を未知のサーバーにアップロードすることに深い懸念を感じました。
|
||||
- そして主流のクラウドストレージやソーシャルアプリは、ログインが必要か、サイズと速度の制限があり、プロセス全体を重くて面倒なものにしていました。
|
||||
|
||||
「**高速、プライベート、アカウント不要**」という3つの中核要件を完全に満たすツールが見つからなかった後、自分で作ることを決意しました。
|
||||
|
||||
### 1.2 個人用ツールから公開プロジェクトへ
|
||||
|
||||
当初、PrivyDropは自分自身のニーズを満たすための小さなユーティリティでした。しかし、機能を徐々に改善していくうちに、私の痛み点がおそらく多くの人々共通のものであることに気づきました。
|
||||
|
||||
データとプライバシーがますます重要になる時代において、私たちは「利便性」と「プライバシー」の間で苦痛な取引を強いられない、より良い選択に値します。この考えがPrivyDropを個人プロジェクトから堅牢で信頼性の高い公開サービスへと磨き上げる原動力となりました。
|
||||
|
||||
私たちの中核的なビジョンはシンプルで、プロジェクトのREADMEに記述した通りです:**すべての人が自分のデータをコントロールできるべきだと信じています。**
|
||||
|
||||
### 1.3 なぜオープンソースなのか?信頼の唯一の答え
|
||||
|
||||
「プライバシーとセキュリティ」を中核価値とするツールにとって、クローズドソースであること自体が矛盾です。ユーザーはどうしてあなたの約束を信頼できるのでしょうか?
|
||||
|
||||
したがって、オープンソースは必然的な選択であり、唯一の答えでした。
|
||||
|
||||
- **信頼の構築**:コードが最良の証明です。私たちはすべてのコードを公開し、世界からの監視を受けることで、議論の余地のない信頼を構築します。
|
||||
- **コミュニティの力**:個人の力には限界があることをよく知っています。コミュニティの集合的知恵が、私が見逃した欠陥を見つけたり、私が思いもよらなかった機能を提案したりして、PrivyDropをさらに進化させ、より堅牢にしてくれると信じています。
|
||||
- **還元と学習**:私はオープンソースコミュニティから多大な恩恵を受けてきました。今が還元するときです。プロジェクトをオープンソースにすることは、才能ある開発者たちから学ぶ機会であり、共有の喜びでもあります。
|
||||
|
||||
## 第2部:アーキテクチャ深掘り:「生産レベル」の実践
|
||||
|
||||
PrivyDropは単なるおもちゃプロジェクトではありません。アーキテクチャ設計において、私たちはシンプルさ、効率性、スケーラビリティを追求し、生産レベルの基準を満たすよう努めています。
|
||||
|
||||
### 2.1 全体像:シンプルで効率的なシステム
|
||||
|
||||
私たちの中核設計原則は:**軽量バックエンド、インテリジェントフロントエンド**です。バックエンドは「交通警察」(シグナリング用)としてのみ機能し、フロントエンドがすべて「重労働」(ファイル処理と転送)を担います。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "ユーザーA (送信者)"
|
||||
A[フロントエンドアプリ]
|
||||
end
|
||||
subgraph "ユーザーB (受信者)"
|
||||
B[フロントエンドアプリ]
|
||||
end
|
||||
subgraph "クラウド"
|
||||
C(シグナリングサーバー - Node.js)
|
||||
D(状態ストレージ - Redis)
|
||||
end
|
||||
|
||||
A -- "1. ルーム作成/参加リクエスト" --> C
|
||||
B -- "2. 同一ルーム参加リクエスト" --> C
|
||||
C -- "3. WebRTCシグナル交換 (SDP/ICE)" --> A
|
||||
C -- "4. WebRTCシグナル交換 (SDP/ICE)" --> B
|
||||
A <-.-> B;
|
||||
C <--> D
|
||||
A <-. "5. P2P直接接続確立" .-> B
|
||||
A -- "6. ファイル/テキスト直接転送" --> B
|
||||
|
||||
style A fill:#D5E8D4,stroke:#82B366
|
||||
style B fill:#D5E8D4,stroke:#82B366
|
||||
```
|
||||
|
||||
### 2.2 フロントエンドアーキテクチャ:関心の分離から論理的凝集へ
|
||||
|
||||
フロントエンドはNext.js 14で構築されており、私たちの最も中核的な設計哲学は**カスタムフックをビジネスロジックの中心とすること**です。
|
||||
|
||||
なぜReduxやZustandを使わないのかと尋ねるかもしれません。PrivyDropの場合、ほとんどの状態は特定の、高凝集なビジネスロジックと密接に関連しています。私たちは一連のカスタムフック(`useWebRTCConnection`、`useRoomManager`、`useFileTransferHandler`など)を使用してこれらのロジックと状態をカプル化し、いくつかの明らかな利点をもたらしました:
|
||||
|
||||
- **論理的凝集**:WebRTC接続に関連するすべての状態とメソッドが`useWebRTCConnection`にまとめられており、極めて保守しやすいです。
|
||||
- **純粋なコンポーネント**:Reactコンポーネントは複雑なビジネスロジックから解放され、UIレンダリングという本質的な役割に戻ります。
|
||||
- **明確な階層化**:`app`(ルーティング)→`components`(UI)→`hooks`(ロジック)→`lib`(低レベル機能)の明確なデータフローと依存関係が形成され、コードベースの保守性が大幅に向上します。
|
||||
|
||||
### 2.3 バックエンドアーキテクチャ:ステートレスと効率性の芸術
|
||||
|
||||
Node.jsとExpressに基づくバックエンドは、設計において厳密に**ステートレス(Stateless)**原則に従っています。
|
||||
|
||||
サーバー自体はルームやユーザーに関連する状態を一切保持しません。すべての状態は**Redis**に委ねられます。これにより、バックエンドアプリケーションを非常に簡単に水平スケールできます。
|
||||
|
||||
私たちはまた、ビジネスニーズを満たすためにRedisの異なるデータ構造を巧みに活用しました:
|
||||
|
||||
- **Hash**: ルームのメタデータを格納する
|
||||
- **Set**: ルーム内のすべてのメンバーの`socketId`を格納し、一意性を保証する
|
||||
- **String**: `socketId`を`roomId`に逆マッピングし、ユーザー切断時の迅速なクリーンアップを容易にする
|
||||
- **Sorted Set**: IPベースのレート制限を実装し、悪意のある攻撃を効果的に防ぐ
|
||||
|
||||
すべてのキーには合理的なTTL(有効期間)が設定されており、リソースの自動クリーンアップを保証し、システムを長期にわたり安定して実行できるようにしています。
|
||||
|
||||
### 2.4 「生産レベル」の考慮事項:デプロイメントからセキュリティまで
|
||||
|
||||
私たちは、包括的な本番環境デプロイメントプランを提供しています:
|
||||
|
||||
- **Nginx**をリバースプロキシおよびSSL終端として使用
|
||||
- **PM2**によるNode.jsプロセス管理
|
||||
- **Certbot**によるSSL証明書の自動取得と更新
|
||||
- 複雑なNATのトラバーサルが必要なシナリオのための包括的な**TURN/STUN**サーバー設定ガイド
|
||||
|
||||
これらはすべて、PrivyDropが信頼でき、本番環境にデプロイ可能な深刻なプロジェクトであることを示しています。
|
||||
|
||||
## 第3部:コードを超えて:未来を共に築くための招待
|
||||
|
||||
オープンソースは始まりにすぎません。私たちはPrivyDropのためにエキサイティングな未来を計画しており、今、あなたに参加してほしいと願っています。
|
||||
|
||||
### 3.1 プロジェクトロードマップ
|
||||
|
||||
私たちは公開の[<u>**プロジェクトロードマップ**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md)を持っており、未来の優先事項を概説しています。将来的に、以下のような要望の高い機能を追加する予定です:
|
||||
|
||||
- **レジューム可能な転送**:非常に大きなファイルと不安定なネットワーク状況に対応するため
|
||||
- **E2E暗号化グループチャット**:安全なP2P通信をマルチユーザーテキストチャットに拡張する
|
||||
- その他未定の機能
|
||||
|
||||
### 3.2 貢献するには?
|
||||
|
||||
私たちはあらゆる形式の貢献を歓迎します!あなたが誰であれ、PrivyDropをより良くする方法が必ずあります。[<u>**貢献ガイドライン**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md)をお読みいただき、あなたの旅を始めてください。
|
||||
|
||||
- **ユーザーの皆さん**:製品を使用し、[GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues)を通じてバグを報告し、機能を提案してください
|
||||
- **開発者の皆さん**:バグを募集し、新機能を実装し、既存のコードをリファクタリングしてください
|
||||
- **ドキュメンター/翻訳者の皆さん**:ドキュメントの改善やPrivyDropの多言語化を手伝ってください
|
||||
|
||||
### 3.3 力強い行動喚起
|
||||
|
||||
- **ユーザーの皆さん**:今すぐPrivyDropを体験し、究極のプライバシーと利便性を感じてください!
|
||||
[**➡️ 今すぐ体験**](https://www.privydrop.app/)
|
||||
|
||||
- **開発者の皆さん**:PrivyDropの哲学や技術に目を輝かせたなら、私たちのGitHubリポジトリにStarを付けてください!それは私たちにとって最高の評価と励みになります。
|
||||
[**⭐️ GitHubでStarを付ける**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
- **すべての皆さん**:コミュニティディスカッションに参加し、私たちにあなたの声を聞かせてください!
|
||||
|
||||
## 結論
|
||||
|
||||
この物語を読む時間を作ってくださり、再度感謝申し上げます。
|
||||
|
||||
PrivyDropの物語は一人の人間のニーズから始まりましたが、その未来はコミュニティによって書かれることを期待しています。
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: "PrivyDrop를 오픈소스로 만든 이유: 개인정보 보호, WebRTC, 그리고 커뮤니티 구축의 이야기"
|
||||
description: "PrivyDrop이 이제 오픈소스가 되었습니다! 이 글은 개인적인 필요에서 시작하여 프로덕션급 개인 파일 전송 도구로 발전한 과정을 설명하고, 아키텍처를 깊이 파고들며, 함께 미래를 만들어갈 당신을 초대합니다."
|
||||
date: "2025-07-07"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-open-source.jpg"
|
||||
tags: [오픈소스, WebRTC, 개인정보 보호, 보안, Next.js, Node.js]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## 서론
|
||||
|
||||
오늘, 제가 마음과 영혼을 쏟아부은 개인 프로젝트인 **PrivyDrop**이 공식적으로 오픈소스가 되었음을 매우 기쁘게 알려드립니다!
|
||||
|
||||
[**지금 바로 사용해보세요 »**](https://www.privydrop.app/) | [**GitHub 저장소 »**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
이 프로젝트는 매우 간단한 개인적인 필요에서 시작되었습니다: "그냥 휴대폰과 컴퓨터 사이에 안전하고 쉽게 파일을 보내고 싶어요."
|
||||
|
||||
만약 당신도 저처럼, 등록이 필요 없고 속도 제한이 없으며, 진정으로 당신의 개인정보를 존중하는 파일 공유 도구를 찾다가 좌절했다면, 이 글은 당신을 위한 것입니다. 이 글은 제 '가려운 곳을 긁는' 이야기를 공유할 뿐만 아니라, PrivyDrop의 핵심 아키텍처와 디자인 철학을 탐구하는 완전한 '비하인드 스토리' 투어를 안내할 것입니다. 그리고 가장 중요한 것은, 당신을 다음 장의 공동 저자로 초대하는 진심 어린 초대장입니다.
|
||||
|
||||
## 1부: 도구의 탄생: "내가 필요해"에서 "모두가 사용할 수 있게"까지
|
||||
|
||||
### 1.1 개발자의 자기 필요 해소 여정
|
||||
|
||||
모든 것은 제 일상 워크플로우의 작지만 지속적인 불편함에서 시작되었습니다.
|
||||
|
||||
저는 자주 휴대폰과 노트북 사이에 파일, 스크린샷, 또는 텍스트 조각을 빠르게 보내야 합니다. 많은 도구를 시도했지만, 어느 것도 제 요구사항을 완전히 만족시키지 못했습니다:
|
||||
|
||||
- 일부 온라인 P2P 도구는 강력했지만 파일만 보낼 수 있어서 가벼운 텍스트나 링크를 보내는 필요에 부응하지 못했습니다.
|
||||
- 일부 온라인 클립보드는 텍스트를 편리하게 동기화할 수 있었지만, 클립보드 내용을 알 수 없는 서버에 업로드하는 것에 대해 깊은 우려를 가졌습니다.
|
||||
- 그리고 메인스트림 클라우드 저장소나 소셜 앱은 로그인이 필요하거나 크기와 속도 제한이 있어 전체 과정이 불편하고 번거롭게 느껴졌습니다.
|
||||
|
||||
제 세 가지 핵심 요구사항—**빠르고, 사적이며, 계정이 필요 없는**—에 완벽하게 맞는 도구를 찾지 못한 후, 스스로 하나를 만들기로 결정했습니다.
|
||||
|
||||
### 1.2 개인 유틸리티에서 공개 프로젝트로
|
||||
|
||||
처음에 PrivyDrop은 제 자신의 필요를 만족시키는 작은 유틸리티였습니다. 하지만 점차 기능을 개선하면서 제 불편함이 아마도 많은 사람들의 공통된 불편함일 것이라는 것을 깨달았습니다.
|
||||
|
||||
데이터와 개인정보 보호가 점점 더 중요해지는 시대에서, 우리는 "편리함"과 "개인정보 보호" 사이에서 고통스러운 타협을 강요받지 않는 더 나은 선택을 가치 있습니다. 이 아이디어는 PrivyDrop을 개인 프로젝트에서 견고하고 신뢰할 수 있는 공개 서비스로 다듬게 하는 원동력이 되었습니다.
|
||||
|
||||
저희의 핵심 비전은 간단합니다. 프로젝트 README에 쓴 것처럼: **우리는 모든 사람이 자신의 데이터를 통제할 수 있어야 한다고 믿습니다.**
|
||||
|
||||
### 1.3 왜 오픈소스인가? 신뢰를 위한 유일한 답
|
||||
|
||||
"개인정보 보호와 보안"을 핵심 가치로 주장하는 도구에 있어, 소스 코드를 닫는 것은 그 자체로 모순입니다. 사용자들이 어떻게 당신의 약속을 신뢰할 수 있을까요?
|
||||
|
||||
따라서 오픈소스는 필연적인 선택이자 유일한 답이었습니다.
|
||||
|
||||
- **신뢰 구축**: 코드가 최고의 증거입니다. 저희는 세계의 검토를 받을 수 있도록 모든 코드를 공개하여, 논쟁의 여지가 없는 신뢰를 구축하고 있습니다.
|
||||
- **커뮤니티의 힘**: 개인의 힘은 제한적이라는 것을 잘 알고 있습니다. 커뮤니티의 집단 지성이 제가 놓친 결함을 발견하고 제가 상상하지 못했던 기능을 제안하여, PrivyDrop이 더 나아가고 더 견고해지도록 도울 수 있다고 믿습니다.
|
||||
- **보답과 학습**: 저는 오픈소스 커뮤니티에서 엄청난 혜택을 받았고, 이제 제가 보답할 차례입니다. 프로젝트를 오픈소스로 만드는 것은 재능 있는 개발자들로부터 배우는 기회이자 공유의 기쁨입니다.
|
||||
|
||||
## 2부: 아키텍처 심층 분석: "프로덕션급" 실천
|
||||
|
||||
PrivyDrop은 단순한 장난감 프로젝트가 아닙니다. 아키텍처 설계에서 저희는 단순함, 효율성, 확장성을 추구하며 프로덕션급 표준을 만족시키기 위해 노력했습니다.
|
||||
|
||||
### 2.1 큰 그림: 단순하고 효율적인 시스템
|
||||
|
||||
저희의 핵심 설계 원칙은: **가벼운 백엔드, 지능적인 프론트엔드**입니다. 백엔드는 "교통 경찰"(시그널링용)으로만 작동하고, 프론트엔드는 모든 "무거운 작업"(파일 처리와 전송)을 처리합니다.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "사용자 A (발신자)"
|
||||
A[프론트엔드 앱]
|
||||
end
|
||||
subgraph "사용자 B (수신자)"
|
||||
B[프론트엔드 앱]
|
||||
end
|
||||
subgraph "클라우드"
|
||||
C(시그널링 서버 - Node.js)
|
||||
D(상태 저장소 - Redis)
|
||||
end
|
||||
|
||||
A -- "1. 방 생성/참여 요청" --> C
|
||||
B -- "2. 동일 방 참여 요청" --> C
|
||||
C -- "3. WebRTC 신호 교환 (SDP/ICE)" --> A
|
||||
C -- "4. WebRTC 신호 교환 (SDP/ICE)" --> B
|
||||
A <-.-> B;
|
||||
C <--> D
|
||||
A <-. "5. P2P 직접 연결 설정" .-> B
|
||||
A -- "6. 파일/텍스트 직접 전송" --> B
|
||||
|
||||
style A fill:#D5E8D4,stroke:#82B366
|
||||
style B fill:#D5E8D4,stroke:#82B366
|
||||
```
|
||||
|
||||
### 2.2 프론트엔드 아키텍처: 관심사 분리에서 논리적 응집까지
|
||||
|
||||
프론트엔드는 Next.js 14로 구축되었으며, 저희의 핵심 설계 철학은 **사용자 정의 Hooks를 비즈니스 로직의 핵심으로 사용**하는 것입니다.
|
||||
|
||||
왜 Redux나 Zustand를 사용하지 않았는지 물을 수 있습니다. PrivyDrop의 경우, 대부분의 상태가 특정하고 응집도가 높은 비즈니스 로직과 밀접하게 결합되어 있습니다. 저희는 일련의 사용자 정의 Hooks(`useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler` 등)를 사용하여 이 로직과 상태를 캡슐화했으며, 이는 몇 가지 명확한 이점을 가져왔습니다:
|
||||
|
||||
- **논리적 응집**: WebRTC 연결과 관련된 모든 상태와 메서드가 `useWebRTCConnection`에 있어 유지보수가 극도로 쉽습니다.
|
||||
- **순수 컴포넌트**: React 컴포넌트는 복잡한 비즈니스 로직에서 해방되어 UI 렌더링이라는 본질적인 역할로 돌아갑니다.
|
||||
- **명확한 계층화**: 이것은 `app` (라우팅) -> `components` (UI) -> `hooks` (로직) -> `lib` (저수준 기능)의 명확한 데이터 흐름과 의존 관계를 만들어 코드베이스의 유지보수성을 크게 향상시킵니다.
|
||||
|
||||
### 2.3 백엔드 아키텍처: 무상태성과 효율성의 예술
|
||||
|
||||
Node.js와 Express 기반의 백엔드는 설계에서 엄격하게 **무상태(Stateless)** 원칙을 따릅니다.
|
||||
|
||||
서버 자체는 방이나 사용자와 관련된 상태를 유지하지 않습니다. 모든 상태는 **Redis**에 위임됩니다. 이를 통해 백엔드 애플리케이션을 매우 쉽게 수평적으로 확장할 수 있습니다.
|
||||
|
||||
저희는 또한 비즈니스 요구를 충족시키기 위해 Redis의 다양한 데이터 구조를 교묘하게 활용했습니다:
|
||||
|
||||
- **Hash**: 방 메타데이터를 저장하기 위해
|
||||
- **Set**: 방 내 모든 멤버의 `socketId`를 저장하여 고유성 보장
|
||||
- **String**: `socketId`를 `roomId`로 역매핑하여 사용자 연결 끊김 시 빠른 정리 용이화
|
||||
- **Sorted Set**: IP 기반 속도 제한을 구현하여 악의적인 공격 효과적으로 방지
|
||||
|
||||
모든 키는 합리적인 TTL(Time To Live)로 설정되어 자동 리소스 정리를 보장하고 시스템이 장기적으로 안정적으로 실행되도록 합니다.
|
||||
|
||||
### 2.4 "프로덕션급" 고려사항: 배포에서 보안까지
|
||||
|
||||
저희는 포괄적인 프로덕션 배포 계획을 제공합니다:
|
||||
|
||||
- **Nginx**를 리버스 프록시 및 SSL 종료로 사용
|
||||
- **PM2**를 통한 Node.js 프로세스 관리
|
||||
- **Certbot**을 통한 SSL 인증서 자동 획득 및 갱신
|
||||
- 복잡한 NAT 통과가 필요한 시나리오를 위한 포괄적인 **TURN/STUN** 서버 설정 가이드
|
||||
|
||||
이 모든 것은 PrivyDrop이 신뢰할 수 있고 프로덕션 환경에 배포할 수 있는 심각한 프로젝트임을 보여줍니다.
|
||||
|
||||
## 3부: 코드를 넘어서: 미래를 함께 만들기 위한 초대
|
||||
|
||||
오픈소스는 시작일 뿐입니다. 저희는 PrivyDrop을 위해 흥미로운 미래를 계획하고 있으며, 이제 당신이 저희와 함께하기를 원합니다.
|
||||
|
||||
### 3.1 프로젝트 로드맵
|
||||
|
||||
저희는 미래의 우선순위를 개요하는 공개 [<u>**프로젝트 로드맵**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md)을 가지고 있습니다. 미래에 다음과 같이 많은 요청이 있었던 기능들을 추가할 계획입니다:
|
||||
|
||||
- **재개 가능한 전송**: 매우 큰 파일과 불안정한 네트워크 상황을 처리하기 위해
|
||||
- **E2E 암호화 그룹 채팅**: 안전한 P2P 통신을 다중 사용자 텍스트 채팅으로 확장
|
||||
- 기타 미정 기능
|
||||
|
||||
### 3.2 기여하는 방법?
|
||||
|
||||
저희는 모든 형태의 기여를 환영합니다! 당신이 누구든, PrivyDrop을 더 나게 만드는 방법이 항상 있습니다. 당신의 여정을 시작하기 위해 저희의 [<u>**기여 가이드라인**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md)을 읽어주세요.
|
||||
|
||||
- **사용자**: 제품을 사용하고, [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues)를 통해 버그를 보고하고 기능을 제안하세요
|
||||
- **개발자**: 버그를 담당하고, 새로운 기능을 구현하거나, 기존 코드의 일부를 리팩토링하세요
|
||||
- **문서 작성자/번역가**: 문서 개선을 도와주시거나 PrivyDrop을 더 많은 언어로 번역해주세요
|
||||
|
||||
### 3.3 강력한 행동 촉구
|
||||
|
||||
- **사용자**: 지금 바로 PrivyDrop을 경험하고 궁극의 개인정보 보호와 편리함을 느껴보세요!
|
||||
[**➡️ 지금 바로 사용해보세요**](https://www.privydrop.app/)
|
||||
|
||||
- **개발자**: PrivyDrop의 철학이나 기술에 감명받았다면, 저희 GitHub 저장소에 Star를 주세요! 이것은 저희에게 가장 큰 인정과 격려입니다.
|
||||
[**⭐️ GitHub에서 Star 주기**](https://github.com/david-bai00/PrivyDrop)
|
||||
|
||||
- **모두**: 저희 커뮤니티 토론에 참여하여 당신의 목소리를 들려주세요!
|
||||
|
||||
## 결론
|
||||
|
||||
이 이야기를 읽는 시간을 내어주셔서 다시 한번 감사드립니다.
|
||||
|
||||
PrivyDrop의 이야기는 한 사람의 필요로 시작되었고, 그 미래가 커뮤니티에 의해 쓰여지기를 기대합니다.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: "Neuer Meilenstein für PrivyDrop: Wiederaufnehmbare Übertragungen beenden die Großdatei‑Angst"
|
||||
description: "Großes Update: Mit wiederaufnehmbaren Übertragungen meisterst du Netzwerkabbrüche gelassen und sendest Gigabyte‑Dateien souverän. So funktioniert’s – und so vereinen wir maximale Zuverlässigkeit mit Privatsphäre."
|
||||
date: "2025-08-01"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
|
||||
tags: ["Neue Funktion", "Wiederaufnehmbare Übertragungen", "Dateiübertragung", "Privatsphäre", "Open Source"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Einleitung: der letzte Schritt von „nutzbar“ zu „liebgewonnen“
|
||||
|
||||
In früheren Artikeln haben wir [<u>**den Open‑Source‑Geist von PrivyDrop**</u>](/blog/privydrop-open-source) und die Grundlage [<u>**WebRTC**</u>](/blog/webrtc-file-transfer) vorgestellt. Herausgekommen ist ein sicheres, privates, P2P‑basiertes Werkzeug zum Dateienteilen.
|
||||
|
||||
Doch Wert entsteht nicht nur dadurch, dass etwas „funktioniert“, sondern dadurch, wie es sich im Alltag anfühlt. Stell dir vor: Im Café mit wackeligem WLAN schickst du ein dringendes 4‑GB‑Video. Die Leiste kriecht auf 95 % – dann ein Abbruch.
|
||||
|
||||
Dieses Gefühl, im letzten Moment zu scheitern, kennen wir alle.
|
||||
|
||||
Heute machen wir den Schritt zu Ende: Wir veröffentlichen **wiederaufnehmbare Übertragungen** – und beenden die „Transfer‑Angst“ endgültig.
|
||||
|
||||
## Das Kernstück: Wie funktioniert die smarte Wiederaufnahme?
|
||||
|
||||
Bevor wir zur Anwendung kommen: Wie „merkt“ sich PrivyDrop den Fortschritt?
|
||||
|
||||
### Das Prinzip: Wie entsteht diese „Erinnerung“?
|
||||
|
||||
Denke an ein riesiges digitales Puzzle:
|
||||
|
||||
1. **Den „Bauplan“ austauschen**: Vor dem Start sendet der Sender einen Bauplan (Metadaten: Name, Gesamtgröße, Chunk‑Info). Der Empfänger sieht das große Ganze.
|
||||
2. **Nummerierte Puzzleteile**: Die Datei wird in kleine, nummerierte Stücke zerlegt. Jedes Teil landet an der vorgesehenen Stelle.
|
||||
3. **Schlauer Kassensturz nach Abbruch**: Bei Unterbrechung behält der Empfänger alle Teile. Nach dem Neuverbinden prüft er: „#1 bis #5000 habe ich – bitte ab #5001 weiter.“
|
||||
|
||||
Dieses „Inventar und Anfordern“ ist der Kern: Kein Neustart von vorn, deutlich höhere Effizienz und Zuverlässigkeit.
|
||||
|
||||
### Praxisleitfaden: richtig reibungslos wiederaufnehmen
|
||||
|
||||
So setzt du es alltagstauglich ein.
|
||||
|
||||
**Schritt 1: „Sicherheitsmodus“ aktivieren (Empfänger)**
|
||||
|
||||
Vor dem Empfang klicke **„Speicherverzeichnis festlegen“**. Damit sagst du: „Gib der Datei ein sicheres Zuhause.“ Erst dann ist die Wiederaufnahme aktiv.
|
||||
|
||||
**Schritt 2: Wenn es unterbrochen wird**
|
||||
|
||||
Netzschwankungen, Tab versehentlich geschlossen, Rechner schläft ein … keine Panik. Dein Fortschritt bleibt bestehen.
|
||||
|
||||
**Schritt 3: Doppelte Absicherung**
|
||||
|
||||
PrivyDrop bietet **zwei Schutzmechanismen**:
|
||||
|
||||
**Aktiver Schutz: „Sicher speichern“‑Button**
|
||||
|
||||
Während der Übertragung siehst du den grünen **„Sicher speichern“**‑Knopf – unser Rettungsboot für stürmische Netze.
|
||||
|
||||
- **Wann sichtbar**: Nach Wahl des Speicherorts, immer während aktiver Übertragung.
|
||||
- **Wann nutzen**: Netz wird zäh? Droht ein Abbruch? Musst du kurz weg? Jederzeit klicken.
|
||||
- **Wirkung**: Bereits empfangene Fragmente sofort auf die Platte schreiben – bereit für den nächsten Anlauf.
|
||||
|
||||
**💡 Tipp**: Keine Zeit zum Klicken? Schließen oder Neuladen der Seite hat den gleichen Schutzeffekt – der Exit‑Schutz speichert automatisch.
|
||||
|
||||
**Passiver Schutz: Exit‑Schutzmechanismus**
|
||||
|
||||
Zusätzlich greift der **Exit‑Schutzmechanismus** beim Schließen/Reload: **Schreibströme werden sauber beendet und empfangene Daten finalisiert**. Aus der temporären Datei wird ein stabiler „Speicherpunkt“.
|
||||
|
||||
Normalerweise wirft der Browser unfertige temporäre Dateien weg, wenn man eine aktive Seite schließt. PrivyDrop lässt deinen Einsatz nicht verpuffen.
|
||||
|
||||
**Schritt 4: Nahtlose Wiederaufnahme**
|
||||
|
||||
Betritt mit dem Sender erneut denselben Raum und starte die Übertragung. Die Leiste springt an die alte Position und läuft gelassen weiter. Voilà.
|
||||
|
||||
## Von der „Einzelaufgabe“ zur „laufenden Zusammenarbeit“: dein privater Datenkanal
|
||||
|
||||
Nach der Zuverlässigkeit kommt die Natürlichkeit. Statt „jedes Mal von vorn“ bietet PrivyDrop einen **persistenten privaten Datenkanal**.
|
||||
|
||||
Sobald ihr verbunden seid, wird der Raum zum gemeinsamen „Büro“. Inhalte teilt ihr fortlaufend, ohne den Zyklus „Raum erstellen, Link teilen“ ständig zu wiederholen.
|
||||
|
||||
1. Du sendest zunächst einen erklärenden Text aus der Zwischenablage.
|
||||
2. Danach ziehst du ein Mockup (PNG) rüber.
|
||||
3. Direkt im Anschluss eine Mappe mit allen Assets.
|
||||
|
||||
So fließend wie ein Gespräch am Schreibtisch nebenan. Aus einem „Einmal‑Tool“ wird ein **leichtgewichtiger, hochprivater Echtzeit‑Kollaborationsraum**.
|
||||
|
||||
## Wenn „Zuverlässigkeit“ auf „Privatsphäre“ trifft
|
||||
|
||||
Kostet die neue Stärke Privatsphäre?
|
||||
|
||||
**Nein.** Wir bauen Funktionen auf einem Fundament aus Privacy & Security.
|
||||
|
||||
- **Wiederaufnahme & Ende‑zu‑Ende‑Verschlüsselung**: Auch Fragmente sind per DTLS Ende‑zu‑Ende verschlüsselt – Browser zu Browser. Der Server kann nichts einsehen, zusammensetzen oder speichern.
|
||||
- **„Unbegrenzte Größe“ × „Wiederaufnahme“**: Unser Gold‑Duo. Größenlimit frei ist das Versprechen; Wiederaufnahme ist die Versicherung. Ganze Festplatten oder große Datasets? Nur zu – ohne Angst, Fortschritt zu verlieren.
|
||||
- **Open‑Source‑Vertrauen**: Nicht nur Behauptung, sondern Einsicht in den Code: Alles liegt offen im [<u>**GitHub‑Repository**</u>](https://github.com/david-bai00/PrivyDrop).
|
||||
|
||||
## Fazit: Willkommen zur „furchtlosen“ Übertragung
|
||||
|
||||
PrivyDrop ist mehr als ein Werkzeug – es wird dein verlässlicher Partner für persönliche und berufliche Daten.
|
||||
|
||||
Besuche [<u>**privydrop.app**</u>](https://www.privydrop.app), schnapp dir die größte Datei und **aktualisiere die Seite absichtlich während der Übertragung**. Spüre, wie beruhigend „Wiederfinden“ ist.
|
||||
|
||||
Und wenn dich robuste, verlässliche Tools begeistern, gib uns einen Stern auf [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop). Danke für deinen Rückenwind!
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: "Nuevo hito de PrivyDrop: transferencias reanudables para decir adiós a la ansiedad de los archivos gigantes"
|
||||
description: "PrivyDrop se renueva: la nueva transferencia reanudable te permite afrontar cortes de red y mover archivos de varios gigas sin miedo. Descubre cómo funciona y cómo unimos fiabilidad extrema y privacidad."
|
||||
date: "2025-08-01"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
|
||||
tags: ["Nueva función", "Transferencias reanudables", "Transferencia de archivos", "Privacidad", "Código abierto"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introducción: el último tramo de “usable” a “disfrutable”
|
||||
|
||||
En artículos anteriores presentamos [<u>**el espíritu de código abierto de PrivyDrop**</u>](/blog/privydrop-open-source) y la tecnología que lo sostiene, [<u>**WebRTC**</u>](/blog/webrtc-file-transfer). Construimos una herramienta de intercambio de archivos segura y privada, basada en P2P.
|
||||
|
||||
Pero el valor real no está solo en que “funcione”, sino en cómo se siente en el mundo real. Imagina que, con el Wi‑Fi inestable de una cafetería, envías un vídeo de 4 GB urgente a un cliente. La barra sube… 95%… y de pronto, la red cae.
|
||||
|
||||
Esa punzada de frustración al final, la conocemos bien.
|
||||
|
||||
Hoy nos alegra anunciar que PrivyDrop ha recorrido ese último tramo. Lanzamos oficialmente las **transferencias reanudables**, para poner fin a tu “ansiedad de transferencia”.
|
||||
|
||||
## La pieza clave: ¿cómo funcionan las transferencias reanudables inteligentes?
|
||||
|
||||
Antes de usarlas, quizá te preguntes: ¿cómo “recuerda” PrivyDrop el progreso?
|
||||
|
||||
### El principio al desnudo: ¿cómo se logra esa “memoria”?
|
||||
|
||||
Nos gusta compararlo con montar un gran rompecabezas digital:
|
||||
|
||||
1. **Intercambiar el “plano”**: Antes de empezar, el emisor envía un “plano” del archivo (metadatos: nombre, tamaño total, información de fragmentos). El receptor entiende el cuadro completo.
|
||||
2. **Piezas numeradas**: El archivo se divide en pequeños fragmentos numerados. El receptor coloca cada pieza en su lugar según llega.
|
||||
3. **Inventario inteligente tras la interrupción**: Si se corta, el receptor conserva las piezas recibidas. Al reconectar, revisa el plano y le dice al emisor: “tengo la #1 a la #5000, empieza por la #5001”.
|
||||
|
||||
Este mecanismo de “inventario y petición” evita empezar de cero y eleva la eficiencia y fiabilidad en archivos grandes.
|
||||
|
||||
### Guía práctica: cómo dominar la reanudación
|
||||
|
||||
Veamos cómo aprovecharla de verdad.
|
||||
|
||||
**Paso 1: Activa el “modo seguro” (receptor)**
|
||||
|
||||
Antes de recibir, pulsa **“Establecer carpeta de guardado”**. Es crucial: le dices a PrivyDrop “prepara un hogar seguro para el archivo que llega”. Así se activa la reanudación.
|
||||
|
||||
**Paso 2: Cuando haya una interrupción**
|
||||
|
||||
Variaciones de red, cerrar la pestaña por error, suspensión del equipo… cuando pase, calma. Tu progreso está a salvo.
|
||||
|
||||
**Paso 3: Doble protección, tranquilidad real**
|
||||
|
||||
PrivyDrop ofrece **dos capas de protección**:
|
||||
|
||||
**Protección activa: botón "Guardado seguro"**
|
||||
|
||||
Mientras se transfiere, verás un botón verde **“Guardado seguro”**. Es el “bote salvavidas” para mares agitados.
|
||||
|
||||
- **Cuándo aparece**: Tras elegir la carpeta de guardado, estará ahí siempre que haya transferencia en curso.
|
||||
- **Cuándo usarlo**: ¿La red va pesada? ¿Temes un corte? ¿Te ausentas un momento? Haz clic cuando quieras.
|
||||
- **Qué hace**: Vuelca inmediatamente los fragmentos recibidos al disco y deja todo listo para reanudar.
|
||||
|
||||
**💡 Consejo**: Si no te da tiempo a pulsarlo, cerrar o recargar la página activa el mismo resguardo: el mecanismo de salida guarda el progreso automáticamente.
|
||||
|
||||
**Protección pasiva: Mecanismo de Protección al Salir**
|
||||
|
||||
Además, el **Mecanismo de Protección al Salir** intercepta el cierre/recarga y actúa como un archivero responsable: **cierra con elegancia la escritura en disco y consolida los datos ya recibidos**. El archivo temporal se convierte en un “punto de guardado” listo para la siguiente sesión.
|
||||
|
||||
Normalmente, si cierras una página con una descarga activa, el navegador puede descartar el temporal y perder datos. Con PrivyDrop, tu esfuerzo no se desperdicia.
|
||||
|
||||
**Paso 4: Reanudación sin costuras**
|
||||
|
||||
Vuelve con el emisor a la misma sala y reinicia la transferencia. Verás la barra saltar al punto exacto y seguir su camino. Eso es reanudar.
|
||||
|
||||
## De la “tarea única” a la “colaboración continua”: tu canal de datos privado
|
||||
|
||||
Resuelta la fiabilidad, queríamos que el intercambio fuera natural. Las herramientas tradicionales son “una y otra vez”. PrivyDrop ofrece un **canal privado persistente**.
|
||||
|
||||
Una vez conectados, la sala es vuestro “despacho” compartido. Compartid contenido sin repetir “crear sala, compartir enlace”.
|
||||
|
||||
1. Envías un texto por el portapapeles.
|
||||
2. Al recibirlo, arrastras un mockup (PNG).
|
||||
3. Al terminar, arrastras una carpeta con todos los recursos.
|
||||
|
||||
El flujo es conversacional, como con un colega al lado. Así, PrivyDrop deja de ser un “pasador de archivos” para ser un **espacio de colaboración ligero y privado en tiempo real**.
|
||||
|
||||
## Cuando la “fiabilidad” se da la mano con la “privacidad”
|
||||
|
||||
¿Todo esto sacrifica la privacidad?
|
||||
|
||||
**En absoluto**. Cada función se construye sobre privacidad y seguridad.
|
||||
|
||||
- **Reanudación + cifrado extremo a extremo**: Incluso los fragmentos para reanudar viajan cifrados (DTLS) y van de navegador a navegador. El servidor no puede inspeccionar, recomponer ni almacenar nada.
|
||||
- **“Tamaño ilimitado” × “reanudación”**: Nuestra combinación dorada. Sin límite de tamaño es la promesa; la reanudación es el seguro que la cumple. Copias un disco entero o mueves datasets enormes sin miedo a perder avance.
|
||||
- **Confianza de código abierto**: No solo lo decimos: lo mostramos. Cada línea está en [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop), abierta al escrutinio.
|
||||
|
||||
## Cierre: te invitamos a una transferencia “sin miedo”
|
||||
|
||||
PrivyDrop ya no es solo una herramienta: es un aliado para tus datos, inmune a los sobresaltos.
|
||||
|
||||
Entra en [<u>**privydrop.app**</u>](https://www.privydrop.app), elige tu archivo más grande y **recarga a propósito a mitad de camino**. Siente la calma de recuperar el terreno.
|
||||
|
||||
Si te entusiasma crear herramientas ultraseguras y fiables, déjanos una estrella en [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop). ¡Tu apoyo nos impulsa!
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: "Nouveau cap pour PrivyDrop : des transferts reprenables pour en finir avec l’angoisse des gros fichiers"
|
||||
description: "PrivyDrop s’étoffe : la reprise de transfert permet d’affronter les coupures réseau et d’envoyer des fichiers de plusieurs Go sans crainte. Voici le fonctionnement et l’alliance fiabilité/confidentialité."
|
||||
date: "2025-08-01"
|
||||
author: "david bai"
|
||||
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
|
||||
tags: ["Nouvelle fonctionnalité", "Transferts reprenables", "Transfert de fichiers", "Confidentialité", "Open source"]
|
||||
status: "published"
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introduction : le dernier pas de « utilisable » à « agréable »
|
||||
|
||||
Nous avons déjà présenté [<u>**l’esprit open‑source de PrivyDrop**</u>](/blog/privydrop-open-source) et la technologie au cœur du projet, [<u>**WebRTC**</u>](/blog/webrtc-file-transfer). Nous avons bâti un partage de fichiers sûr et privé, en P2P.
|
||||
|
||||
Mais la valeur d’un outil ne tient pas qu’à « ça marche », elle tient à la sensation d’usage. Imagine : au café, Wi‑Fi capricieux, tu envoies un rendu vidéo de 4 Go. La barre grimpe à 95 %… et la connexion lâche.
|
||||
|
||||
Cette défaite à la dernière seconde, on l’a tous vécue.
|
||||
|
||||
Aujourd’hui, nous franchissons ce pas final : nous lançons les **transferts reprenables**, pour mettre un terme à l’angoisse des transferts.
|
||||
|
||||
## L’arme maîtresse : comment fonctionne la reprise intelligente ?
|
||||
|
||||
Avant la prise en main : comment PrivyDrop « se souvient‑il » de l’avancée ?
|
||||
|
||||
### Le principe : d’où vient cette « mémoire » ?
|
||||
|
||||
Pense à un gigantesque puzzle numérique :
|
||||
|
||||
1. **Échanger le « plan »** : Avant d’envoyer, l’expéditeur partage un plan (métadonnées : nom, taille, fragments). Le destinataire voit l’ensemble.
|
||||
2. **Des pièces numérotées** : Le fichier est découpé en petits morceaux numérotés. Chaque pièce est posée à sa place.
|
||||
3. **Inventaire malin après coupure**: À l’interruption, le destinataire garde ses pièces. À la reconnexion, il vérifie et dit : « j’ai #1 à #5000, reprenons à #5001 ».
|
||||
|
||||
Ce mécanisme « inventaire + requête » évite le redémarrage et booste efficacité et fiabilité sur les gros volumes.
|
||||
|
||||
### Guide pratique : bien utiliser la reprise
|
||||
|
||||
Passons au concret.
|
||||
|
||||
**Étape 1 : activer le « mode sûr » (réception)**
|
||||
|
||||
Avant de recevoir, clique **« Définir le dossier de sauvegarde »**. Tu indiques à PrivyDrop : « prépare un foyer sûr ». La reprise s’active alors.
|
||||
|
||||
**Étape 2 : quand une coupure survient**
|
||||
|
||||
Réseau fluctuant, onglet fermé, mise en veille… garde ton calme. La progression est préservée.
|
||||
|
||||
**Étape 3 : double protection pour l’esprit tranquille**
|
||||
|
||||
PrivyDrop offre **deux protections** :
|
||||
|
||||
**Protection active : bouton "Enregistrement sécurisé"**
|
||||
|
||||
Pendant l’envoi, un bouton vert **« Enregistrement sécurisé »** apparaît. Notre canot de sauvetage pour mers agitées.
|
||||
|
||||
- **Quand il s’affiche** : après choix du dossier, tant qu’un transfert est en cours.
|
||||
- **Quand cliquer** : réseau lent ? crainte d’une coupure ? besoin de s’absenter ? à tout moment.
|
||||
- **Effet** : écrit immédiatement les fragments reçus sur le disque, prêt pour la reprise.
|
||||
|
||||
**💡 Astuce** : Pas le temps de cliquer ? Fermer ou recharger la page déclenche la même protection : le mécanisme de sortie sauvegarde automatiquement.
|
||||
|
||||
**Protection passive : mécanisme de protection à la fermeture**
|
||||
|
||||
En plus, le **mécanisme de protection à la fermeture** intercepte la sortie : **flux d’écriture soigneusement fermés, données déjà reçues finalisées**. Le temporaire devient un « point de sauvegarde » solide.
|
||||
|
||||
En temps normal, fermer une page en téléchargement peut faire perdre le fichier temporaire. Avec PrivyDrop, rien n’est perdu.
|
||||
|
||||
**Étape 4 : reprise sans couture**
|
||||
|
||||
Reviens avec l’expéditeur dans la même salle et relance. La barre saute au bon endroit et continue paisiblement. C’est la magie de la reprise.
|
||||
|
||||
## De la « tâche unique » à la « collaboration continue » : ton canal privé
|
||||
|
||||
Après la fiabilité, la fluidité. Plutôt que « recommencer à chaque fois », PrivyDrop propose un **canal de données privé persistant**.
|
||||
|
||||
Une fois connectés, la salle devient votre « bureau » partagé. Partage en continu, sans recréer/renvoyer le lien.
|
||||
|
||||
1. Tu envoies un texte par le presse‑papiers.
|
||||
2. Puis tu déposes une maquette (PNG).
|
||||
3. Ensuite un dossier avec tous les assets.
|
||||
|
||||
Le tout se déroule comme une conversation. PrivyDrop passe d’un « utilitaire ponctuel » à un **espace de collaboration léger, privé et temps réel**.
|
||||
|
||||
## Quand « fiabilité » rime avec « confidentialité »
|
||||
|
||||
Ces nouveautés coûtent‑elles la vie privée ?
|
||||
|
||||
**Absolument pas.** Tout repose sur la confidentialité et la sécurité.
|
||||
|
||||
- **Reprise + chiffrement de bout en bout** : Même fragmenté, tout transite chifré (DTLS) de navigateur à navigateur. Le serveur ne voit, ne recompose, ni ne stocke rien.
|
||||
- **« Taille illimitée » × « reprise »** : Notre duo doré. L’absence de limite est la promesse ; la reprise, l’assurance qui la tient. Disque entier ou dataset massif, sans peur de perdre le terrain.
|
||||
- **Confiance open‑source** : Nous montrons notre code. Tout est public dans [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop).
|
||||
|
||||
## Conclusion : on t’invite à un transfert « sans peur »
|
||||
|
||||
PrivyDrop n’est plus un simple outil : c’est l’allié fiable de tes données.
|
||||
|
||||
Visite [<u>**privydrop.app**</u>](https://www.privydrop.app), choisis ton plus gros fichier et **actualise exprès en plein transfert**. Ressens la sérénité du « retrouvé ».
|
||||
|
||||
Si bâtir des outils ultra‑fiables te parle, offre‑nous une étoile sur [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop). Merci pour ton soutien !
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user