Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d155c608 | ||
|
|
09d66092f5 | ||
|
|
ca62b9dd6e | ||
|
|
c68acfd78b | ||
|
|
10ea48bf04 | ||
|
|
fbe6abd519 | ||
|
|
22f9bcfa46 | ||
|
|
46974b120e | ||
|
|
3332e7bbcf | ||
|
|
a182043970 | ||
|
|
2150849529 | ||
|
|
dde7beee15 | ||
|
|
dd6ab78353 | ||
|
|
9964b4e3ad |
No files matched your search
+40
-28
@@ -1,45 +1,57 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
ci: domain-linux-native
|
ci: domain-linux-native
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
|
|
||||||
clone:
|
clone:
|
||||||
- name: git
|
- name: git
|
||||||
image: woodpeckerci/plugin-git:2.9.3
|
image: woodpeckerci/plugin-git:2.9.3
|
||||||
settings:
|
settings:
|
||||||
remote:
|
remote:
|
||||||
from_secret: gitea_clone_webdrop
|
from_secret: gitea_clone_webdrop
|
||||||
lfs: false
|
lfs: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
api-contract:
|
prefetch-dependencies:
|
||||||
image: bash
|
image: bash
|
||||||
commands:
|
commands:
|
||||||
- node scripts/check-api-contract.mjs
|
- mkdir -p "$HOME/.cache/woodpecker/webdrop/npm" "$HOME/.cache/woodpecker/webdrop/nuget"
|
||||||
|
- cd packages/core
|
||||||
|
- npm ci --cache "$HOME/.cache/woodpecker/webdrop/npm" --prefer-offline --no-audit
|
||||||
|
- cd ../../web
|
||||||
|
- npm ci --cache "$HOME/.cache/woodpecker/webdrop/npm" --prefer-offline --no-audit
|
||||||
|
- cd ..
|
||||||
|
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
|
||||||
|
|
||||||
|
api-contract:
|
||||||
|
image: bash
|
||||||
|
commands:
|
||||||
|
- node scripts/check-api-contract.mjs
|
||||||
|
|
||||||
core-and-web:
|
core-and-web:
|
||||||
image: bash
|
image: bash
|
||||||
|
depends_on: [prefetch-dependencies]
|
||||||
commands:
|
commands:
|
||||||
- cd packages/core
|
- cd packages/core
|
||||||
- npm ci
|
|
||||||
- npm run typecheck
|
- npm run typecheck
|
||||||
- npm test
|
- npm test
|
||||||
- npm run vectors:check
|
- npm run vectors:check
|
||||||
- npm run build
|
- npm run build
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: bash
|
image: bash
|
||||||
|
depends_on: [prefetch-dependencies]
|
||||||
commands:
|
commands:
|
||||||
- cd web
|
- cd web
|
||||||
- npm ci
|
|
||||||
- npm run typecheck
|
- npm run typecheck
|
||||||
- npm run lint:css
|
- npm run lint:css
|
||||||
- npm test
|
- npm test
|
||||||
- npm run build
|
- npm run build
|
||||||
|
|
||||||
server:
|
server:
|
||||||
image: bash
|
image: bash
|
||||||
|
depends_on: [prefetch-dependencies]
|
||||||
commands:
|
commands:
|
||||||
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
|
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
|
||||||
@@ -1,38 +1,63 @@
|
|||||||
when:
|
when:
|
||||||
- event: tag
|
- event: tag
|
||||||
ref: refs/tags/v*
|
ref: refs/tags/v*
|
||||||
|
- event: manual
|
||||||
labels:
|
|
||||||
ci: domain-linux-native
|
labels:
|
||||||
platform: linux/amd64
|
ci: domain-linux-native
|
||||||
|
platform: linux/amd64
|
||||||
clone:
|
|
||||||
- name: git
|
clone:
|
||||||
image: woodpeckerci/plugin-git:2.9.3
|
- name: git
|
||||||
settings:
|
image: woodpeckerci/plugin-git:2.9.3
|
||||||
remote:
|
settings:
|
||||||
from_secret: gitea_clone_webdrop
|
remote:
|
||||||
lfs: false
|
from_secret: gitea_clone_webdrop
|
||||||
|
lfs: false
|
||||||
steps:
|
|
||||||
release-checks:
|
steps:
|
||||||
image: bash
|
checkout-release-source:
|
||||||
commands:
|
image: bash
|
||||||
- node scripts/check-api-contract.mjs "$CI_COMMIT_TAG"
|
commands:
|
||||||
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
|
- set -euo pipefail
|
||||||
|
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
|
||||||
publish-backend:
|
- test -n "$release_tag"
|
||||||
image: bash
|
- node scripts/check-api-contract.mjs "$release_tag"
|
||||||
environment:
|
- git fetch --force origin "refs/tags/$release_tag:refs/tags/$release_tag"
|
||||||
ZOT_USERNAME:
|
- git checkout --detach "refs/tags/$release_tag"
|
||||||
from_secret: registry_username
|
|
||||||
ZOT_PASSWORD:
|
release-checks:
|
||||||
from_secret: registry_password
|
image: bash
|
||||||
ZOT_REGISTRY: zot.oeo.one
|
commands:
|
||||||
commands:
|
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
|
||||||
- set -euo pipefail
|
- test -n "$release_tag"
|
||||||
- printf '%s' "$ZOT_PASSWORD" | buildah login "$ZOT_REGISTRY" --username "$ZOT_USERNAME" --password-stdin
|
- node scripts/check-api-contract.mjs "$release_tag"
|
||||||
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
|
- mkdir -p "$HOME/.cache/woodpecker/webdrop/nuget"
|
||||||
- image="$ZOT_REGISTRY/justme/webdrop-api:$CI_COMMIT_TAG"
|
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
|
||||||
- buildah bud --format docker --platform linux/amd64 --tag "$image" --file server/Dockerfile .
|
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
|
||||||
- buildah push "$image" "docker://$image"
|
|
||||||
|
publish-backend:
|
||||||
|
image: bash
|
||||||
|
environment:
|
||||||
|
ZOT_USERNAME:
|
||||||
|
from_secret: registry_username
|
||||||
|
ZOT_PASSWORD:
|
||||||
|
from_secret: registry_password
|
||||||
|
ZOT_REGISTRY: 192.168.2.162:5000
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
|
||||||
|
- test -n "$release_tag"
|
||||||
|
- ': "${WOODPECKER_BACKEND_LOCAL_TEMP_DIR:=$HOME/woodpecker/work}"'
|
||||||
|
- buildah_store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/webdrop"
|
||||||
|
- mkdir -p "$buildah_store/storage" "$buildah_store/run"
|
||||||
|
- 'buildah() { command buildah --root "$buildah_store/storage" --runroot "$buildah_store/run" "$@"; }'
|
||||||
|
- printf '%s' "$ZOT_PASSWORD" | buildah login --tls-verify=false "$ZOT_REGISTRY" --username "$ZOT_USERNAME" --password-stdin
|
||||||
|
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
|
||||||
|
- image="$ZOT_REGISTRY/justme/webdrop-api:$release_tag"
|
||||||
|
- latest_image="$ZOT_REGISTRY/justme/webdrop-api:latest"
|
||||||
|
- buildah bud --layers --network host --format docker --platform linux/amd64 --tag "$image" --file server/Dockerfile .
|
||||||
|
- buildah tag "$image" "$latest_image"
|
||||||
|
- buildah push --tls-verify=false "$image" "docker://$image"
|
||||||
|
- buildah push --tls-verify=false "$latest_image" "docker://$latest_image"
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 协议、API、威胁模型 | 已定稿(`docs/`) |
|
| 协议、API、威胁模型 | 已定稿(`docs/`) |
|
||||||
| `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 |
|
| `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 |
|
||||||
| `server/` | 频道与便捷分享 API 已实现并通过 132 个测试;win-x64 与 linux-x64 NativeAOT 发布此前已验证零警告 |
|
| `server/` | 频道与便捷分享 API 已实现;含独立的可选 `WebDrop.Frontend` 静态资源模块;NativeAOT 发布 |
|
||||||
| `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 |
|
| `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 |
|
||||||
| `web/` | 前端,开发中 |
|
| `web/` | 前端,开发中 |
|
||||||
|
|
||||||
@@ -45,6 +45,6 @@ webdrop-proj/
|
|||||||
├─ docs/
|
├─ docs/
|
||||||
├─ test-vectors/ 协议测试向量,所有实现共用
|
├─ test-vectors/ 协议测试向量,所有实现共用
|
||||||
├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用)
|
├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用)
|
||||||
├─ server/ .NET 10,NativeAOT
|
├─ server/ .NET 10,NativeAOT;可将 Vite dist 嵌入 WebDrop.Frontend
|
||||||
└─ web/ Vite + React + TypeScript
|
└─ web/ Vite + React + TypeScript
|
||||||
```
|
```
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
## 1. 组件
|
## 1. 组件
|
||||||
|
|
||||||
```
|
```
|
||||||
浏览器 (React, 独立域名 + SRI) 服务端 (.NET 10, NativeAOT)
|
浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
|
||||||
┌──────────────────────────────┐ ┌─────────────────────────────┐
|
┌──────────────────────────────┐ ┌─────────────────────────────┐
|
||||||
│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │
|
│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │
|
||||||
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
|
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
(元数据、日志) (磁盘,预留 S3)
|
(元数据、日志) (磁盘,预留 S3)
|
||||||
```
|
```
|
||||||
|
|
||||||
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。
|
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
|
||||||
|
|
||||||
## 2. 技术栈
|
## 2. 技术栈
|
||||||
|
|
||||||
@@ -84,8 +84,9 @@ SQLite 存元数据和小帧,磁盘存大帧。
|
|||||||
|
|
||||||
## 5. 部署
|
## 5. 部署
|
||||||
|
|
||||||
- API 与前端使用**不同域名**:前端静态资源可以放在 CDN 或独立静态主机,API 被攻破不影响前端。
|
- 安全隔离部署时,API 与前端使用**不同域名**:前端静态资源可以放在独立静态主机,API 被攻破不影响前端代码。
|
||||||
- 前端构建产物为所有脚本和样式生成 SRI,发布时公布构建哈希。
|
- Compose 单机模式可以把 Vite dist 嵌入 `WebDrop.Frontend`,由 API 同源提供页面;此模式简化部署,但 API 主机也是前端代码的信任根。
|
||||||
|
- 独立托管时,前端资源应使用 HTTPS 发布;若部署 SRI,发布时公布构建哈希。
|
||||||
- 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。
|
- 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。
|
||||||
- 反向代理不得记录请求头(令牌)和完整 URL。
|
- 反向代理不得记录请求头(令牌)和完整 URL。
|
||||||
- 服务端日志不含频道 id 明文,只含截断后的哈希。
|
- 服务端日志不含频道 id 明文,只含截断后的哈希。
|
||||||
|
|||||||
+7
-5
@@ -1,6 +1,6 @@
|
|||||||
# 本地后端 Docker Compose
|
# 本地后端 Docker Compose
|
||||||
|
|
||||||
Compose 当前只启动 WebDrop API。前端继续使用现有的开发/独立托管方式;本地 Vite 开发服务器通过 `/api/v1` 代理访问 `127.0.0.1:5000`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。
|
Compose 当前只启动一个 WebDrop API 容器;构建镜像时会先构建 `web/`,再由独立的 `WebDrop.Frontend` 模块将静态资源嵌入后端。访问 `http://127.0.0.1:5000/` 打开前端,API 使用同源 `/api/v1`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -15,10 +15,12 @@ API 地址为 `http://127.0.0.1:5000`。停止服务使用 `docker compose down`
|
|||||||
|
|
||||||
## 前端连接
|
## 前端连接
|
||||||
|
|
||||||
本机 `npm run dev` 使用 Vite 代理连接默认 API。独立部署前端时,在前端托管层把 `/api/v1` 代理到此 API 地址,这样无需开放 CORS。也可以为跨域托管配置 `WebDrop__CorsOrigins`,值应为明确的前端源列表。
|
本机开发仍可使用 `cd web; npm run dev`,Vite 会代理 `/api/v1` 到本地 API。独立托管前端时,可单独构建 `web/` 并在前端托管层反向代理 `/api/v1`;跨域直连则配置明确的 `WebDrop__CorsOrigins`。
|
||||||
|
|
||||||
公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。
|
公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。
|
||||||
|
|
||||||
|
嵌入模式减少自托管组件,但前端代码与 API 来自同一个部署信任域。若要让 API 节点被攻破时仍不能替换前端,应使用独立域名托管前端。
|
||||||
|
|
||||||
## 验证码
|
## 验证码
|
||||||
|
|
||||||
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置:
|
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置:
|
||||||
@@ -34,11 +36,11 @@ CAPTCHA_SECRET=<server-side-secret>
|
|||||||
|
|
||||||
## Woodpecker CI
|
## Woodpecker CI
|
||||||
|
|
||||||
`.woodpecker/ci.yml` 在 push 和手动运行时执行 API 契约、Core、Web、服务端检查;每次 push 都会构建前端。`.woodpecker/release-backend.yml` 仅响应 `vMAJOR.MINOR.PATCH` SemVer tag;测试通过后用 Linux native agent 的 Buildah 构建并推送同名后端镜像 tag。两者都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆私有仓库。
|
`.woodpecker/ci.yml` 在 push 和手动运行时执行 API 契约、Core、Web、服务端检查;每次 push 都会构建前端。`.woodpecker/release-backend.yml` 响应 `vMAJOR.MINOR.PATCH` SemVer tag;也可手动运行并传入 `RELEASE_TAG`(例如 `v0.1.0`)。测试通过后用 Linux native agent 的 Buildah 构建并推送同名后端镜像 tag。两者都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆公开仓库。
|
||||||
|
|
||||||
两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。Gitea webhook 走同 Docker 网络中的 `woodpecker-server:8000`,代码 clone 走公网 Git 域名。
|
两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。release job 在 `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` 对应的持久目录缓存 Buildah 层,Dockerfile 单独缓存 NuGet restore 层。Gitea webhook 走同 Docker 网络中的 `woodpecker-server:8000`,代码 clone 走公网 Git 域名。
|
||||||
|
|
||||||
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。在 Woodpecker 仓库配置 tag 事件可用的 secrets `registry_username`、`registry_password`。发布步骤要求 Linux native agent 安装 Buildah;使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`,仓库名已填入 `.env.example`。
|
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。Woodpecker Linux agent 通过内网 `192.168.2.162:5000` 访问同一 Zot 仓库,发布需要 Buildah 与 tag/manual 事件可用的 secrets `registry_username`、`registry_password`。使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`,仓库名已填入 `.env.example`。
|
||||||
|
|
||||||
API 契约版本以 `contracts/api-contract.json` 为准,并同步到 OpenAPI 与后端 `/api/v1/info`。加字段等向后兼容更新递增 minor,兼容性修复递增 patch,破坏性更新递增 major。前端声明支持的契约主版本;后端主版本变化时,前端 CI 契约检查和运行时连接检查都会失败,直到前端同步支持该主版本。
|
API 契约版本以 `contracts/api-contract.json` 为准,并同步到 OpenAPI 与后端 `/api/v1/info`。加字段等向后兼容更新递增 minor,兼容性修复递增 patch,破坏性更新递增 major。前端声明支持的契约主版本;后端主版本变化时,前端 CI 契约检查和运行时连接检查都会失败,直到前端同步支持该主版本。
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,13 @@
|
|||||||
|
|
||||||
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
|
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
|
||||||
|
|
||||||
第一版的缓解:
|
隔离部署的缓解:
|
||||||
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端。
|
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端代码。
|
||||||
- 所有脚本使用 SRI,构建产物带版本哈希,发布时公布哈希。
|
- 可由独立托管方校验脚本 SRI 与构建哈希;前端托管方被攻破的风险仍然存在。
|
||||||
- 前端托管方被攻破的风险仍然存在。
|
|
||||||
|
嵌入式部署的取舍:
|
||||||
|
- `WebDrop.Frontend` 将 Vite dist 嵌入后端程序集,让 API 以同源方式提供页面。
|
||||||
|
- 这只简化部署,不增强前端与后端的隔离。若部署主机或镜像被控制,攻击者仍可替换页面并窃取用户后续输入的密钥。
|
||||||
|
|
||||||
后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。
|
后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,22 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
||||||
yield* chunksOf(source.stream());
|
if (typeof source.stream === 'function') {
|
||||||
|
yield* chunksOf(source.stream());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older Safari releases don't expose Blob.stream(). Read bounded slices
|
||||||
|
// instead of materializing the complete file in memory.
|
||||||
|
const sliceSize = 256 * 1024;
|
||||||
|
for (let offset = 0; offset < source.size; offset += sliceSize) {
|
||||||
|
const part = source.slice(offset, Math.min(offset + sliceSize, source.size));
|
||||||
|
if (typeof part.arrayBuffer === 'function') {
|
||||||
|
yield new Uint8Array(await part.arrayBuffer());
|
||||||
|
} else {
|
||||||
|
yield await readBlobPart(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||||
@@ -153,6 +168,18 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
|
|||||||
yield* source as AsyncIterable<Uint8Array>;
|
yield* source as AsyncIterable<Uint8Array>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBlobPart(blob: Blob): Promise<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => reader.result instanceof ArrayBuffer
|
||||||
|
? resolve(new Uint8Array(reader.result))
|
||||||
|
: reject(new TypeError('文件读取失败'));
|
||||||
|
reader.onerror = () => reject(reader.error ?? new TypeError('文件读取失败'));
|
||||||
|
reader.onabort = () => reject(new DOMException('文件读取已取消', 'AbortError'));
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface Chunk {
|
interface Chunk {
|
||||||
data: Bytes;
|
data: Bytes;
|
||||||
last: boolean;
|
last: boolean;
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
|
FROM node:24.21.0-alpine3.23 AS frontend
|
||||||
|
WORKDIR /src
|
||||||
|
COPY web/package.json web/package-lock.json ./web/
|
||||||
|
COPY packages/core/package.json ./packages/core/package.json
|
||||||
|
RUN cd web && npm ci --no-audit --no-fund
|
||||||
|
COPY packages/core/ ./packages/core/
|
||||||
|
COPY web/ ./web/
|
||||||
|
RUN npm run build --prefix web
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
|
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY server/Directory.Build.props ./server/
|
||||||
|
COPY server/src/WebDrop.Api/WebDrop.Api.csproj ./server/src/WebDrop.Api/
|
||||||
|
COPY server/src/WebDrop.Core/WebDrop.Core.csproj ./server/src/WebDrop.Core/
|
||||||
|
COPY server/src/WebDrop.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/
|
||||||
|
COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/
|
||||||
|
RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \
|
||||||
|
--runtime linux-x64
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
|
COPY --from=frontend /src/web/dist/ ./web/dist/
|
||||||
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
|
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
|
||||||
--configuration Release \
|
--configuration Release \
|
||||||
--runtime linux-x64 \
|
--runtime linux-x64 \
|
||||||
--self-contained true \
|
--self-contained true \
|
||||||
|
--no-restore \
|
||||||
--output /out
|
--output /out
|
||||||
RUN find /root/.nuget/packages/sqlitepclraw.lib.e_sqlite3 -path '*/runtimes/linux-x64/native/libe_sqlite3.so' \
|
RUN find /root/.nuget/packages/sqlitepclraw.lib.e_sqlite3 -path '*/runtimes/linux-x64/native/libe_sqlite3.so' \
|
||||||
-exec cp {} /out/libe_sqlite3.so \; \
|
-exec cp {} /out/libe_sqlite3.so \; \
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 |
|
| `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 |
|
||||||
| `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) |
|
| `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) |
|
||||||
|
| `src/WebDrop.Frontend` | 可选嵌入 Vite dist,提供静态文件、SPA fallback 与页面 CSP;不依赖业务层 |
|
||||||
| `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 |
|
| `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 |
|
||||||
| `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) |
|
| `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) |
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet test # 全部测试
|
dotnet test # 全部测试
|
||||||
dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000
|
dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000
|
||||||
|
npm --prefix ../web run build # 先构建前端时,dotnet publish 会自动嵌入 web/dist
|
||||||
|
|
||||||
# NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里)
|
# NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里)
|
||||||
$env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer"
|
$env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer"
|
||||||
@@ -47,6 +49,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用
|
|||||||
- 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。
|
- 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。
|
||||||
- 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。
|
- 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。
|
||||||
- NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。
|
- NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。
|
||||||
|
- `WebDrop.Frontend` 只负责嵌入静态资源与 SPA fallback。Dockerfile 自动构建 `web/`;单独发布 API 时,只有 `web/dist` 存在或指定 `WebDropFrontendDist` 后才包含前端。
|
||||||
|
|
||||||
## 验证码(Cap)
|
## 验证码(Cap)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/WebDrop.Api/WebDrop.Api.csproj" />
|
<Project Path="src/WebDrop.Api/WebDrop.Api.csproj" />
|
||||||
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
|
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
|
||||||
|
<Project Path="src/WebDrop.Frontend/WebDrop.Frontend.csproj" />
|
||||||
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
|
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
|
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\WebDrop.Frontend\WebDrop.Frontend.csproj" />
|
||||||
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
|
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using WebDrop.Core;
|
using WebDrop.Core;
|
||||||
|
using WebDrop.Frontend;
|
||||||
using WebDrop.Storage;
|
using WebDrop.Storage;
|
||||||
|
|
||||||
namespace WebDrop.Api;
|
namespace WebDrop.Api;
|
||||||
@@ -80,6 +81,7 @@ public static class WebDropApp
|
|||||||
if (options.CorsOrigins.Length > 0) app.UseCors();
|
if (options.CorsOrigins.Length > 0) app.UseCors();
|
||||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
|
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
|
||||||
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
|
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,7 @@ public static class WebDropApp
|
|||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>API 不提供任何页面。禁用缓存、禁止嗅探、不发送 Referer、不允许被嵌入。</summary>
|
/// <summary>API 默认禁止缓存、嗅探和嵌入;嵌入式前端的页面策略由静态模块单独设置。</summary>
|
||||||
private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next)
|
private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next)
|
||||||
{
|
{
|
||||||
var h = ctx.Response.Headers;
|
var h = ctx.Response.Headers;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace WebDrop.Frontend;
|
||||||
|
|
||||||
|
/// <summary>在同源模式下,从本程序集资源提供 Vite 前端。</summary>
|
||||||
|
public static class EmbeddedFrontend
|
||||||
|
{
|
||||||
|
private const string ResourcePrefix = "WebDrop.Frontend.Assets/";
|
||||||
|
|
||||||
|
public static void Use(IApplicationBuilder app, string? extraConnectOrigin = null)
|
||||||
|
{
|
||||||
|
var assembly = typeof(EmbeddedFrontend).Assembly;
|
||||||
|
var resources = assembly.GetManifestResourceNames()
|
||||||
|
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal))
|
||||||
|
.ToDictionary(
|
||||||
|
name => name[ResourcePrefix.Length..].Replace('\\', '/'),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (!resources.ContainsKey("index.html")) return;
|
||||||
|
|
||||||
|
var contentSecurityPolicy = BuildContentSecurityPolicy(extraConnectOrigin);
|
||||||
|
app.Use((HttpContext context, RequestDelegate next) =>
|
||||||
|
HandleAsync(context, next, assembly, resources, contentSecurityPolicy));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleAsync(
|
||||||
|
HttpContext context,
|
||||||
|
RequestDelegate next,
|
||||||
|
System.Reflection.Assembly assembly,
|
||||||
|
IReadOnlyDictionary<string, string> resources,
|
||||||
|
string contentSecurityPolicy)
|
||||||
|
{
|
||||||
|
if (!(HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizePath(context.Request.Path.Value);
|
||||||
|
if (normalized is null)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (IsApiPath(normalized))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.TryGetValue(normalized, out var resourceName))
|
||||||
|
{
|
||||||
|
await SendAsync(context, assembly, resources, normalized, contentSecurityPolicy, resourceName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing Vite asset must stay a 404. Other unknown paths are client-side routes.
|
||||||
|
if (normalized.StartsWith("assets/", StringComparison.Ordinal) || normalized.Equals("favicon.ico", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendAsync(context, assembly, resources, "index.html", contentSecurityPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendAsync(
|
||||||
|
HttpContext context,
|
||||||
|
System.Reflection.Assembly assembly,
|
||||||
|
IReadOnlyDictionary<string, string> resources,
|
||||||
|
string path,
|
||||||
|
string contentSecurityPolicy,
|
||||||
|
string? resourceName = null)
|
||||||
|
{
|
||||||
|
resourceName ??= resources[path];
|
||||||
|
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||||
|
if (stream is null)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||||
|
context.Response.ContentType = ContentType(path);
|
||||||
|
context.Response.Headers["Cache-Control"] = path.StartsWith("assets/", StringComparison.Ordinal)
|
||||||
|
? "public, max-age=31536000, immutable"
|
||||||
|
: "no-store";
|
||||||
|
|
||||||
|
// Dedicated workers enforce the CSP returned with their own script response.
|
||||||
|
// Give bundled JS the same app policy so the crypto worker can call the same-origin API.
|
||||||
|
if (path.Equals("index.html", StringComparison.Ordinal) || path.EndsWith(".js", StringComparison.OrdinalIgnoreCase))
|
||||||
|
context.Response.Headers["Content-Security-Policy"] = contentSecurityPolicy;
|
||||||
|
|
||||||
|
if (!HttpMethods.IsHead(context.Request.Method))
|
||||||
|
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizePath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return "index.html";
|
||||||
|
var normalized = path.Replace('\\', '/').Trim('/');
|
||||||
|
if (normalized.Length == 0) return "index.html";
|
||||||
|
if (normalized.Contains('\0') || normalized.Split('/').Any(segment => segment is ".." or "." or ""))
|
||||||
|
return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsApiPath(string path) =>
|
||||||
|
path.Equals("api", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.StartsWith("api/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string ContentType(string path) => Path.GetExtension(path).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".html" => "text/html; charset=utf-8",
|
||||||
|
".js" => "text/javascript; charset=utf-8",
|
||||||
|
".css" => "text/css; charset=utf-8",
|
||||||
|
".json" => "application/json; charset=utf-8",
|
||||||
|
".svg" => "image/svg+xml",
|
||||||
|
".png" => "image/png",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".ico" => "image/x-icon",
|
||||||
|
".woff2" => "font/woff2",
|
||||||
|
".wasm" => "application/wasm",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string BuildContentSecurityPolicy(string? extraConnectOrigin)
|
||||||
|
{
|
||||||
|
var connectSources = new List<string> { "'self'", "ws:", "wss:" };
|
||||||
|
if (Uri.TryCreate(extraConnectOrigin, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
|
||||||
|
connectSources.Add(uri.GetLeftPart(UriPartial.Authority));
|
||||||
|
|
||||||
|
return "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; " +
|
||||||
|
"form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; worker-src 'self' blob:; " +
|
||||||
|
$"connect-src {string.Join(' ', connectSources)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# WebDrop.Frontend
|
||||||
|
|
||||||
|
独立的 ASP.NET Core 模块,负责提供嵌入程序集的 Vite 前端资源。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
如果 `web/dist/index.html` 存在,项目会将 `web/dist` 作为 embedded resources 编入 `WebDrop.Frontend.dll`。也可通过 MSBuild 属性指定资源目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish ..\WebDrop.Api\WebDrop.Api.csproj -p:WebDropFrontendDist=C:\build\web-dist
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile 会构建 `web/`,再把 dist 传给 .NET 发布阶段。`EmbeddedFrontend.Map` 只映射页面与静态文件,不依赖 `WebDrop.Core` 或存储层。
|
||||||
|
|
||||||
|
开发时可以不构建 dist,此时模块不映射页面路由;使用 `npm run dev` 配合 Vite API 代理。
|
||||||
|
|
||||||
|
## 路由与响应
|
||||||
|
|
||||||
|
- `/` 和未知的前端路由返回 `index.html`,支持 React SPA 深层链接。
|
||||||
|
- `/assets/*` 从程序集资源读取;缺失资源和 `/api/*` 返回 404。
|
||||||
|
- 页面使用同源 CSP,并允许配置的 Cap API origin、WebSocket 与 Worker。
|
||||||
|
- `index.html` 不缓存;Vite `assets/` 使用 immutable 缓存。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RootNamespace>WebDrop.Frontend</RootNamespace>
|
||||||
|
<AssemblyName>WebDrop.Frontend</AssemblyName>
|
||||||
|
<WebDropFrontendDist Condition="'$(WebDropFrontendDist)' == ''">$(MSBuildProjectDirectory)/../../../web/dist</WebDropFrontendDist>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="Exists('$(WebDropFrontendDist)/index.html')">
|
||||||
|
<EmbeddedResource Include="$(WebDropFrontendDist)/**/*">
|
||||||
|
<LogicalName>WebDrop.Frontend.Assets/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -65,7 +65,7 @@ export interface ShareSettings {
|
|||||||
|
|
||||||
/** protocol §7、§14:由帧推导,不单独存储 */
|
/** protocol §7、§14:由帧推导,不单独存储 */
|
||||||
export type ItemState =
|
export type ItemState =
|
||||||
| { kind: 'receiving'; progress: number }
|
| { kind: 'receiving'; progress: number; phase?: 'encrypting' | 'uploading'; speedBytesPerSecond?: number }
|
||||||
| { kind: 'complete' }
|
| { kind: 'complete' }
|
||||||
| { kind: 'incomplete' }
|
| { kind: 'incomplete' }
|
||||||
| { kind: 'tampered' };
|
| { kind: 'tampered' };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { removeChannelItem } from './channelItems';
|
import { removeChannelItem } from './channelItems';
|
||||||
|
import { errorMessage } from '../errors';
|
||||||
|
|
||||||
import { saveResource } from '../database';
|
import { saveResource } from '../database';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ import { deviceIdentity } from './presentation';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { flushOutbox, persistPlan } from './transfer';
|
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
|
||||||
import { publishWorkspace } from './presentation';
|
import { publishWorkspace } from './presentation';
|
||||||
|
|
||||||
export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> {
|
export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> {
|
||||||
@@ -35,24 +36,66 @@ export async function sendFiles(resourceId: string, files: File[]): Promise<void
|
|||||||
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
||||||
const device = deviceIdentity();
|
const device = deviceIdentity();
|
||||||
session.deviceName = device.name;
|
session.deviceName = device.name;
|
||||||
|
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts });
|
let item: FileItem | undefined;
|
||||||
let item: FileItem = {
|
try {
|
||||||
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 },
|
const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts, maxFrameData });
|
||||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
item = {
|
||||||
remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true,
|
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||||
};
|
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||||
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item.id), item];
|
remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true,
|
||||||
await saveResource(resource);
|
};
|
||||||
blobs.set(`${resourceId}:${item.id}`, file);
|
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item];
|
||||||
publishWorkspace(resourceId, { t: 'item', item });
|
await saveResource(resource);
|
||||||
await persistPlan(resource, plan);
|
blobs.set(`${resourceId}:${item.id}`, file);
|
||||||
const uploaded = await flushOutbox(resource, session);
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => {
|
||||||
resource.localItems = resource.localItems.map((entry) => entry.id === item.id ? item : entry);
|
if (!item) return;
|
||||||
await saveResource(resource);
|
item = {
|
||||||
publishWorkspace(resourceId, { t: 'item', item });
|
...item,
|
||||||
|
state: {
|
||||||
|
kind: 'receiving',
|
||||||
|
progress: Math.max(0, Math.min(0.99, progress)),
|
||||||
|
phase,
|
||||||
|
...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
|
};
|
||||||
|
const planned = await persistPlan(resource, plan, (_frames) => {
|
||||||
|
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
|
||||||
|
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
|
||||||
|
});
|
||||||
|
const uploadedFrames = new Set<number>();
|
||||||
|
let uploadedBytes = 0;
|
||||||
|
const uploadStartedAt = performance.now();
|
||||||
|
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
|
||||||
|
if (frame.itemId !== item?.id) return;
|
||||||
|
if (complete && !uploadedFrames.has(frame.index)) {
|
||||||
|
uploadedFrames.add(frame.index);
|
||||||
|
uploadedBytes += totalBytes;
|
||||||
|
}
|
||||||
|
const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes);
|
||||||
|
const elapsed = Math.max(1, performance.now() - uploadStartedAt);
|
||||||
|
updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed);
|
||||||
|
});
|
||||||
|
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
||||||
|
resource.localItems = resource.localItems.map((entry) => entry.id === item?.id ? item! : entry);
|
||||||
|
await saveResource(resource);
|
||||||
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
|
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开频道并连接网络后,可以继续发送已加密内容。' });
|
||||||
|
} catch (error) {
|
||||||
|
if (item) {
|
||||||
|
item = { ...item, state: { kind: 'incomplete' } };
|
||||||
|
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === item?.id ? item! : entry);
|
||||||
|
await saveResource(resource);
|
||||||
|
publishWorkspace(resourceId, { t: 'item', item });
|
||||||
|
}
|
||||||
|
publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ export function setApiBase(value: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code });
|
export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code });
|
||||||
export const apiPath = (path: string): string => `${apiBase.replace(/\/$/, '')}${path}`;
|
const currentOrigin = (): string => typeof self !== 'undefined' && self.location?.origin
|
||||||
|
? self.location.origin
|
||||||
|
: 'http://localhost';
|
||||||
|
export const apiPath = (path: string): string => new URL(
|
||||||
|
`${apiBase.replace(/\/$/, '')}${path}`,
|
||||||
|
currentOrigin(),
|
||||||
|
).toString();
|
||||||
|
|
||||||
function ensureApiContract(): Promise<void> {
|
function ensureApiContract(): Promise<void> {
|
||||||
contractCheck ??= fetch(apiPath('/info'), {
|
contractCheck ??= fetch(apiPath('/info'), {
|
||||||
@@ -52,6 +58,51 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** XHR reports request-body progress, which fetch doesn't expose in browsers. */
|
||||||
|
export async function apiUpload(
|
||||||
|
path: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: ArrayBuffer,
|
||||||
|
onProgress: (loadedBytes: number, totalBytes: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureApiContract();
|
||||||
|
const response = await new Promise<Response>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', apiPath(path));
|
||||||
|
xhr.withCredentials = false;
|
||||||
|
xhr.responseType = 'text';
|
||||||
|
xhr.setRequestHeader('Cache-Control', 'no-store');
|
||||||
|
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) onProgress(event.loaded, event.total);
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 0) {
|
||||||
|
reject(new Error('network_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
const contentType = xhr.getResponseHeader('Content-Type');
|
||||||
|
if (contentType) responseHeaders.set('Content-Type', contentType);
|
||||||
|
resolve(new Response(xhr.responseText, { status: xhr.status, statusText: xhr.statusText, headers: responseHeaders }));
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('network_error'));
|
||||||
|
xhr.onabort = () => reject(new DOMException('上传已取消', 'AbortError'));
|
||||||
|
xhr.send(body);
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||||
|
const code = typeof result?.error === 'string' && /^[a-z_]{1,40}$/.test(result.error) ? result.error : 'request_failed';
|
||||||
|
throw fail(response.status, code);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && 'status' in error) throw error;
|
||||||
|
if (error instanceof Error && error.message === 'api_contract_mismatch') throw error;
|
||||||
|
throw new Error('网络连接失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function readJson<T>(path: string, headers: Record<string, string>): Promise<T> {
|
export async function readJson<T>(path: string, headers: Record<string, string>): Promise<T> {
|
||||||
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
|
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database';
|
import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database';
|
||||||
import type { FileItem, TextItem } from '../types';
|
import type { FileItem, TextItem } from '../types';
|
||||||
import { makeFileItem } from './presentation';
|
import { deviceIdentity, makeFileItem } from './presentation';
|
||||||
import { getSession } from './sessions';
|
import { getSession } from './sessions';
|
||||||
import { apiFetch, fail } from './http';
|
import { apiFetch, fail } from './http';
|
||||||
|
|
||||||
|
|
||||||
import { flushOutbox, persistPlan } from './transfer';
|
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
|
||||||
import { publishShare } from './presentation';
|
import { publishShare } from './presentation';
|
||||||
import { invalidateManifest } from './shareState';
|
import { invalidateManifest } from './shareState';
|
||||||
import { blobs } from './sessions';
|
import { blobs } from './sessions';
|
||||||
|
import { errorMessage } from '../errors';
|
||||||
|
|
||||||
|
|
||||||
function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEntry, { t: 'text' }> } {
|
function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEntry, { t: 'text' }> } {
|
||||||
@@ -36,29 +37,67 @@ export async function addShareFiles(id: string, files: File[]): Promise<void> {
|
|||||||
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
||||||
await invalidateManifest(session, resource);
|
await invalidateManifest(session, resource);
|
||||||
const downloadLimit = resource.maxDownloads ?? 0;
|
const downloadLimit = resource.maxDownloads ?? 0;
|
||||||
|
const maxFrameData = deviceIdentity().kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, {
|
let item: FileItem | undefined;
|
||||||
gated: true, maxDownloads: downloadLimit, ts,
|
try {
|
||||||
});
|
const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, {
|
||||||
const item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0 }, downloadLimit || null);
|
gated: true, maxDownloads: downloadLimit, ts, maxFrameData,
|
||||||
const entry: Extract<ManifestEntry, { t: 'file' }> = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts };
|
});
|
||||||
blobs.set(`${id}:${item.id}`, file);
|
item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0, phase: 'encrypting' }, downloadLimit || null);
|
||||||
try { await saveDraftFile(id, item.id, file); }
|
const entry: Extract<ManifestEntry, { t: 'file' }> = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts };
|
||||||
catch { publishShare(id, { t: 'error', message: '本机存储空间不足,刷新后可能无法预览此文件。' }); }
|
blobs.set(`${id}:${item.id}`, file);
|
||||||
resource.draftItems = [...(resource.draftItems ?? []), item];
|
try { await saveDraftFile(id, item.id, file); }
|
||||||
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
|
catch { publishShare(id, { t: 'error', message: '本机存储空间不足,刷新后可能无法预览此文件。' }); }
|
||||||
await saveResource(resource);
|
resource.draftItems = [...(resource.draftItems ?? []), item];
|
||||||
publishShare(id, { t: 'item', item });
|
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
|
||||||
await persistPlan(resource, plan);
|
await saveResource(resource);
|
||||||
const uploaded = await flushOutbox(resource, session);
|
publishShare(id, { t: 'item', item });
|
||||||
const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => {
|
||||||
resource.draftItems = resource.draftItems.map((draft) => draft.id === item.id ? complete : draft);
|
if (!item) return;
|
||||||
await saveResource(resource);
|
item = {
|
||||||
publishShare(id, { t: 'item', item: complete });
|
...item,
|
||||||
if (!uploaded) {
|
state: {
|
||||||
publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' });
|
kind: 'receiving',
|
||||||
continue;
|
progress: Math.max(0, Math.min(0.99, progress)),
|
||||||
|
phase,
|
||||||
|
...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
publishShare(id, { t: 'item', item });
|
||||||
|
};
|
||||||
|
const planned = await persistPlan(resource, plan, (_frames) => {
|
||||||
|
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
|
||||||
|
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
|
||||||
|
});
|
||||||
|
const uploadedFrames = new Set<number>();
|
||||||
|
let uploadedBytes = 0;
|
||||||
|
const uploadStartedAt = performance.now();
|
||||||
|
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
|
||||||
|
if (frame.itemId !== item?.id) return;
|
||||||
|
if (complete && !uploadedFrames.has(frame.index)) {
|
||||||
|
uploadedFrames.add(frame.index);
|
||||||
|
uploadedBytes += totalBytes;
|
||||||
|
}
|
||||||
|
const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes);
|
||||||
|
const elapsed = Math.max(1, performance.now() - uploadStartedAt);
|
||||||
|
updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed);
|
||||||
|
});
|
||||||
|
const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
||||||
|
resource.draftItems = resource.draftItems.map((draft) => draft.id === item?.id ? complete : draft);
|
||||||
|
await saveResource(resource);
|
||||||
|
publishShare(id, { t: 'item', item: complete });
|
||||||
|
if (!uploaded) publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' });
|
||||||
|
} catch (error) {
|
||||||
|
if (item) {
|
||||||
|
item = { ...item, state: { kind: 'incomplete' } };
|
||||||
|
resource.draftItems = (resource.draftItems ?? []).map((draft) => draft.id === item?.id ? item! : draft);
|
||||||
|
await saveResource(resource);
|
||||||
|
publishShare(id, { t: 'item', item });
|
||||||
|
}
|
||||||
|
publishShare(id, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { fromBase64Url, IntegrityError, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
||||||
import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database';
|
import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database';
|
||||||
import { apiFetch, type ApiError } from './http';
|
import { apiFetch, apiUpload, type ApiError } from './http';
|
||||||
import { serial } from './serial';
|
import { serial } from './serial';
|
||||||
|
|
||||||
export async function persistPlan(resource: SavedResource, plan: ItemPlan): Promise<void> {
|
// Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier.
|
||||||
|
export const MOBILE_UPLOAD_FRAME_DATA_BYTES = 256 * 1024 - 4;
|
||||||
|
export { MAX_FRAME_DATA };
|
||||||
|
const UPLOAD_RETRY_DELAYS_MS = [500, 1500, 4000] as const;
|
||||||
|
|
||||||
|
export async function persistPlan(
|
||||||
|
resource: SavedResource,
|
||||||
|
plan: ItemPlan,
|
||||||
|
onProgress?: (frames: number, bytes: number) => void,
|
||||||
|
): Promise<{ frames: number; bytes: number }> {
|
||||||
const createdAt = Date.now();
|
const createdAt = Date.now();
|
||||||
|
let frames = 0;
|
||||||
|
let bytes = 0;
|
||||||
for await (const frame of plan.frames) {
|
for await (const frame of plan.frames) {
|
||||||
await queueFrame({
|
await queueFrame({
|
||||||
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
|
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
|
||||||
@@ -15,7 +26,11 @@ export async function persistPlan(resource: SavedResource, plan: ItemPlan): Prom
|
|||||||
extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {},
|
extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {},
|
||||||
createdAt: createdAt + frame.index,
|
createdAt: createdAt + frame.index,
|
||||||
});
|
});
|
||||||
|
frames += 1;
|
||||||
|
bytes += frame.bytes.byteLength;
|
||||||
|
onProgress?.(frames, bytes);
|
||||||
}
|
}
|
||||||
|
return { frames, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeRole(session: ChannelSession): 'owner' | 'write' {
|
function writeRole(session: ChannelSession): 'owner' | 'write' {
|
||||||
@@ -25,28 +40,51 @@ function writeRole(session: ChannelSession): 'owner' | 'write' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
|
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
|
||||||
export async function flushOutbox(resource: SavedResource, session: ChannelSession): Promise<boolean> {
|
export async function flushOutbox(
|
||||||
return serial(`outbox:${resource.id}`, () => flush(resource, session));
|
resource: SavedResource,
|
||||||
|
session: ChannelSession,
|
||||||
|
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return serial(`outbox:${resource.id}`, () => flush(resource, session, onFrameProgress));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush(resource: SavedResource, session: ChannelSession): Promise<boolean> {
|
async function flush(
|
||||||
|
resource: SavedResource,
|
||||||
|
session: ChannelSession,
|
||||||
|
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const frame = await nextQueuedFrame(resource.id);
|
const frame = await nextQueuedFrame(resource.id);
|
||||||
if (!frame) return true;
|
if (!frame) return true;
|
||||||
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
||||||
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
||||||
try {
|
let maxLoadedBytes = 0;
|
||||||
await apiFetch(path, {
|
let accepted = false;
|
||||||
method: 'POST',
|
for (let attempt = 0; attempt <= UPLOAD_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||||
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
|
try {
|
||||||
body: frame.bytes.slice().buffer as ArrayBuffer,
|
await apiUpload(
|
||||||
});
|
path,
|
||||||
await dequeueFrame(frame.key);
|
{ ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
|
||||||
} catch (error) {
|
frame.bytes.slice().buffer as ArrayBuffer,
|
||||||
const apiError = error as ApiError;
|
(loaded, total) => {
|
||||||
if (apiError.status === undefined || apiError.status === 429 || apiError.status >= 500) return false;
|
maxLoadedBytes = Math.max(maxLoadedBytes, loaded);
|
||||||
throw error;
|
onFrameProgress?.(frame, maxLoadedBytes, total, false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await dequeueFrame(frame.key);
|
||||||
|
onFrameProgress?.(frame, frame.bytes.byteLength, frame.bytes.byteLength, true);
|
||||||
|
accepted = true;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425
|
||||||
|
|| apiError.status === 429 || apiError.status === 499 || apiError.status >= 500;
|
||||||
|
if (!retryable) throw error;
|
||||||
|
if (attempt === UPLOAD_RETRY_DELAYS_MS.length) return false;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_DELAYS_MS[attempt]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!accepted) return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize, formatTime } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { useDownload } from '@/features/item/download';
|
import { useDownload } from '@/features/item/download';
|
||||||
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
|
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
|
||||||
import { shortDevice, titleOf } from '@/features/item/summary';
|
import { shortDevice, titleOf } from '@/features/item/summary';
|
||||||
@@ -33,6 +33,7 @@ export function LatestCard({ id, onOpen }: Props) {
|
|||||||
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
|
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
|
||||||
<span className={styles.sub}>
|
<span className={styles.sub}>
|
||||||
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
|
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
|
||||||
|
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
|||||||
import { useChannels } from '@/store/channels';
|
import { useChannels } from '@/store/channels';
|
||||||
import { useShares } from '@/store/shares';
|
import { useShares } from '@/store/shares';
|
||||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||||
|
import { errorMessage } from '@/core-bridge/errors';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { ChannelList } from './ChannelList';
|
import { ChannelList } from './ChannelList';
|
||||||
import { CreateSheet } from './CreateSheet';
|
import { CreateSheet } from './CreateSheet';
|
||||||
@@ -22,7 +23,7 @@ export function Home() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [scanOpen, setScanOpen] = useState(false);
|
const [scanOpen, setScanOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failedMessage, setFailedMessage] = useState('');
|
||||||
const [shareText, setShareText] = useState('');
|
const [shareText, setShareText] = useState('');
|
||||||
const [shareFiles, setShareFiles] = useState<File[]>([]);
|
const [shareFiles, setShareFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
@@ -32,15 +33,15 @@ export function Home() {
|
|||||||
async (p: ClipboardPayload) => {
|
async (p: ClipboardPayload) => {
|
||||||
if (busy || (p.files.length === 0 && !p.text.trim())) return false;
|
if (busy || (p.files.length === 0 && !p.text.trim())) return false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFailed(false);
|
setFailedMessage('');
|
||||||
try {
|
try {
|
||||||
const captchaToken = await solveCreationCaptcha();
|
const captchaToken = await solveCreationCaptcha();
|
||||||
open(await createDraft(p, captchaToken));
|
open(await createDraft(p, captchaToken));
|
||||||
setShareFiles([]);
|
setShareFiles([]);
|
||||||
setShareText('');
|
setShareText('');
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (error) {
|
||||||
setFailed(true);
|
setFailedMessage(errorMessage(error));
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -71,7 +72,7 @@ export function Home() {
|
|||||||
onFilesChange={setShareFiles}
|
onFilesChange={setShareFiles}
|
||||||
onShare={share}
|
onShare={share}
|
||||||
/>
|
/>
|
||||||
{failed ? <p role="alert" className={styles.error}>创建分享失败,请重试。</p> : null}
|
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
|
||||||
|
|
||||||
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor:focus-visible {
|
.editor:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: none;
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor::placeholder {
|
.editor::placeholder {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { formatSize, formatTime } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { CopyButton } from '@/ui/CopyButton';
|
import { CopyButton } from '@/ui/CopyButton';
|
||||||
import { useDownload } from './download';
|
import { useDownload } from './download';
|
||||||
@@ -42,6 +42,7 @@ export const CompactRow = memo(function CompactRow({ id, selected, checked, onSe
|
|||||||
<>
|
<>
|
||||||
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
|
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
|
||||||
{item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
|
{item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
|
||||||
|
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
|
||||||
{version > 1 ? <span className={styles.tag}>v{version}</span> : null}
|
{version > 1 ? <span className={styles.tag}>v{version}</span> : null}
|
||||||
{item.kind === 'file' && item.unseen ? <span className={styles.update}>有更新</span> : null}
|
{item.kind === 'file' && item.unseen ? <span className={styles.update}>有更新</span> : null}
|
||||||
{item.kind === 'text' && item.conflict ? <span className={styles.conflict}>冲突</span> : null}
|
{item.kind === 'text' && item.conflict ? <span className={styles.conflict}>冲突</span> : null}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Item } from '@/core-bridge/types';
|
import type { Item } from '@/core-bridge/types';
|
||||||
import { isLongText } from '@/lib/fileKind';
|
import { isLongText } from '@/lib/fileKind';
|
||||||
import { formatSize, formatTime } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { CopyButton } from '@/ui/CopyButton';
|
import { CopyButton } from '@/ui/CopyButton';
|
||||||
import { useDownload } from './download';
|
import { useDownload } from './download';
|
||||||
@@ -71,7 +71,7 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props)
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{receiving !== null ? <p className={styles.dim}>传输中 {Math.round(receiving * 100)}%,完成后可预览。</p> : null}
|
{receiving !== null && item.state.kind === 'receiving' ? <p className={styles.dim}>{formatTransferProgress(item.state)} · 完成后可预览。</p> : null}
|
||||||
{receiving === null && editing && item.kind === 'text' ? (
|
{receiving === null && editing && item.kind === 'text' ? (
|
||||||
<TextEditor item={item} onDone={() => setEditing(false)} />
|
<TextEditor item={item} onDone={() => setEditing(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize } from '@/lib/format';
|
import { formatSize, formatTransferProgress } from '@/lib/format';
|
||||||
import { useShareSession } from '@/store/shareSession';
|
import { useShareSession } from '@/store/shareSession';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
import { titleOf } from '@/features/item/summary';
|
import { titleOf } from '@/features/item/summary';
|
||||||
@@ -20,7 +20,7 @@ export function DraftRow({ id, selected, onSelect }: { id: string; selected: boo
|
|||||||
<Tile item={item} />
|
<Tile item={item} />
|
||||||
<span className={styles.text}>
|
<span className={styles.text}>
|
||||||
<span className={styles.title}>{titleOf(item)}</span>
|
<span className={styles.title}>{titleOf(item)}</span>
|
||||||
<span className={styles.sub}>{formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''}</span>
|
<span className={styles.sub}>{formatSize(size)}{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}</span>
|
||||||
{receiving !== null ? (
|
{receiving !== null ? (
|
||||||
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
|
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
|
||||||
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
|
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ export function formatSize(bytes: number): string {
|
|||||||
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatTransferProgress(state: {
|
||||||
|
progress: number;
|
||||||
|
phase?: 'encrypting' | 'uploading';
|
||||||
|
speedBytesPerSecond?: number;
|
||||||
|
}): string {
|
||||||
|
const phase = state.phase === 'encrypting' ? '加密中' : state.phase === 'uploading' ? '上传中' : '传输中';
|
||||||
|
const speed = state.speedBytesPerSecond && state.speedBytesPerSecond > 0
|
||||||
|
? ` · ${formatSize(state.speedBytesPerSecond)}/s`
|
||||||
|
: '';
|
||||||
|
return `${phase} ${Math.round(state.progress * 100)}%${speed}`;
|
||||||
|
}
|
||||||
|
|
||||||
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
|
||||||
export function formatTime(ts: number): string {
|
export function formatTime(ts: number): string {
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ summary,
|
|||||||
|
|
||||||
/* iOS Safari 对小于 16px 的输入框聚焦时会自动放大页面 */
|
/* iOS Safari 对小于 16px 的输入框聚焦时会自动放大页面 */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
input,
|
input:not([type="checkbox"]):not([type="radio"]),
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user