Compare commits
10
Commits
d048fedc5e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2a0c089a | ||
|
|
4b4ab4ee4d | ||
|
|
7e6ef5e587 | ||
|
|
7274390aa1 | ||
|
|
8af4f284a0 | ||
|
|
5489407241 | ||
|
|
17638b6e20 | ||
|
|
ccb2c074d6 | ||
|
|
d3da3597cc | ||
|
|
72a5e5b839 |
No files matched your search
@@ -0,0 +1,51 @@
|
|||||||
|
# 主后端(含内嵌前端)的开发镜像:提交信息带 [CI],并且这次推送改到了主后端用到的代码时才构建。
|
||||||
|
# 推送 webdrop-api:dev(开发机器的 podman auto-update 跟踪它)和 webdrop-api:dev-<提交>
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: [master, main]
|
||||||
|
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'web/**'
|
||||||
|
- 'packages/core/**'
|
||||||
|
- 'server/Containerfile'
|
||||||
|
- 'server/Directory.Build.props'
|
||||||
|
- 'server/src/WebDrop.Api/**'
|
||||||
|
- 'server/src/WebDrop.Core/**'
|
||||||
|
- 'server/src/WebDrop.Storage/**'
|
||||||
|
- 'server/src/WebDrop.Frontend/**'
|
||||||
|
- 'server/src/WebDrop.FileProtocol/**'
|
||||||
|
- 'server/src/WebDrop.Sync/**'
|
||||||
|
- 'scripts/ci-publish-image.sh'
|
||||||
|
- '.woodpecker/dev-image-api.yml'
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
labels:
|
||||||
|
ci: domain-linux-native
|
||||||
|
platform: linux/amd64
|
||||||
|
|
||||||
|
clone:
|
||||||
|
- name: git
|
||||||
|
image: woodpeckerci/plugin-git:2.9.3
|
||||||
|
settings:
|
||||||
|
remote:
|
||||||
|
from_secret: gitea_clone_webdrop
|
||||||
|
lfs: false
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- ci
|
||||||
|
- web-build
|
||||||
|
|
||||||
|
concurrency: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
publish-dev-image:
|
||||||
|
image: bash
|
||||||
|
environment:
|
||||||
|
ZOT_USERNAME: justme
|
||||||
|
ZOT_SECRET:
|
||||||
|
from_secret: zot_secret
|
||||||
|
ZOT_REGISTRY: registry.oeo.one
|
||||||
|
commands:
|
||||||
|
- sha="${CI_COMMIT_SHA:0:7}"
|
||||||
|
- bash scripts/ci-publish-image.sh webdrop-api server/Containerfile "dev-$sha" dev
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 文件服务(fileAPI)的开发镜像:提交信息带 [CI],并且这次推送改到了文件服务用到的代码时才构建。
|
||||||
|
# 推送 webdrop-files:dev(放文件的机器上的 podman auto-update 跟踪它)和 webdrop-files:dev-<提交>
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: [master, main]
|
||||||
|
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'server/Containerfile.files'
|
||||||
|
- 'server/Directory.Build.props'
|
||||||
|
- 'server/src/WebDrop.FileApi/**'
|
||||||
|
- 'server/src/WebDrop.FileProtocol/**'
|
||||||
|
- 'server/src/WebDrop.Sync/**'
|
||||||
|
- 'scripts/ci-publish-image.sh'
|
||||||
|
- '.woodpecker/dev-image-files.yml'
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
labels:
|
||||||
|
ci: domain-linux-native
|
||||||
|
platform: linux/amd64
|
||||||
|
|
||||||
|
clone:
|
||||||
|
- name: git
|
||||||
|
image: woodpeckerci/plugin-git:2.9.3
|
||||||
|
settings:
|
||||||
|
remote:
|
||||||
|
from_secret: gitea_clone_webdrop
|
||||||
|
lfs: false
|
||||||
|
|
||||||
|
# 文件服务不带前端,不用等前端构建
|
||||||
|
depends_on:
|
||||||
|
- ci
|
||||||
|
|
||||||
|
concurrency: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
publish-dev-image:
|
||||||
|
image: bash
|
||||||
|
environment:
|
||||||
|
ZOT_USERNAME: justme
|
||||||
|
ZOT_SECRET:
|
||||||
|
from_secret: zot_secret
|
||||||
|
ZOT_REGISTRY: registry.oeo.one
|
||||||
|
commands:
|
||||||
|
- sha="${CI_COMMIT_SHA:0:7}"
|
||||||
|
- bash scripts/ci-publish-image.sh webdrop-files server/Containerfile.files "dev-$sha" dev
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
when:
|
|
||||||
- event: push
|
|
||||||
branch: master
|
|
||||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
|
||||||
- event: push
|
|
||||||
branch: main
|
|
||||||
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
|
|
||||||
|
|
||||||
labels:
|
|
||||||
ci: domain-linux-native
|
|
||||||
platform: linux/amd64
|
|
||||||
|
|
||||||
clone:
|
|
||||||
- name: git
|
|
||||||
image: woodpeckerci/plugin-git:2.9.3
|
|
||||||
settings:
|
|
||||||
remote:
|
|
||||||
from_secret: gitea_clone_webdrop
|
|
||||||
lfs: false
|
|
||||||
|
|
||||||
depends_on:
|
|
||||||
- ci
|
|
||||||
- web-build
|
|
||||||
|
|
||||||
concurrency: 1
|
|
||||||
|
|
||||||
steps:
|
|
||||||
publish-dev-image:
|
|
||||||
image: bash
|
|
||||||
environment:
|
|
||||||
ZOT_USERNAME: justme
|
|
||||||
ZOT_SECRET:
|
|
||||||
from_secret: zot_secret
|
|
||||||
ZOT_REGISTRY: registry.oeo.one
|
|
||||||
commands:
|
|
||||||
- set -euo pipefail
|
|
||||||
- ': "${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_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
|
|
||||||
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
|
|
||||||
- sha="${CI_COMMIT_SHA:0:7}"
|
|
||||||
- build_tag="dev-$sha"
|
|
||||||
- image="$ZOT_REGISTRY/justme/webdrop-api:dev-$sha"
|
|
||||||
- dev_image="$ZOT_REGISTRY/justme/webdrop-api:dev"
|
|
||||||
- build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
- buildah bud --layers --network host --format oci --platform linux/amd64 --build-arg "WEBDROP_FRONTEND_COMMIT=$CI_COMMIT_SHA" --build-arg "WEBDROP_FRONTEND_TAG=$build_tag" --build-arg "WEBDROP_BACKEND_COMMIT=$CI_COMMIT_SHA" --build-arg "WEBDROP_BACKEND_TAG=$build_tag" --build-arg "WEBDROP_BUILD_TIME=$build_time" --tag "$image" --file server/Containerfile .
|
|
||||||
- buildah tag "$image" "$dev_image"
|
|
||||||
- buildah push "$image" "docker://$image"
|
|
||||||
- buildah push "$dev_image" "docker://$dev_image"
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# 发布:打 vMAJOR.MINOR.PATCH tag(或手动运行并传 RELEASE_TAG)。
|
||||||
|
# 一个 tag 同时发布两个镜像:webdrop-api:<tag> / :latest 和 webdrop-files:<tag> / :latest。
|
||||||
|
# 两边共用票据格式和文件协议,版本号一致最不容易配错;各机器只会拉自己用的那个镜像。
|
||||||
when:
|
when:
|
||||||
- event: tag
|
- event: tag
|
||||||
ref: refs/tags/v*
|
ref: refs/tags/v*
|
||||||
@@ -37,29 +40,26 @@ steps:
|
|||||||
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
|
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
|
||||||
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
|
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
|
||||||
|
|
||||||
publish-backend:
|
publish-api:
|
||||||
image: bash
|
image: bash
|
||||||
|
depends_on: [release-checks]
|
||||||
environment:
|
environment:
|
||||||
ZOT_USERNAME: justme
|
ZOT_USERNAME: justme
|
||||||
ZOT_SECRET:
|
ZOT_SECRET:
|
||||||
from_secret: zot_secret
|
from_secret: zot_secret
|
||||||
ZOT_REGISTRY: registry.oeo.one
|
ZOT_REGISTRY: registry.oeo.one
|
||||||
commands:
|
commands:
|
||||||
- set -euo pipefail
|
|
||||||
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
|
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
|
||||||
- test -n "$release_tag"
|
- bash scripts/ci-publish-image.sh webdrop-api server/Containerfile "$release_tag" latest
|
||||||
- ': "${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_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
|
|
||||||
- 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"
|
|
||||||
- build_commit="$(git rev-parse HEAD)"
|
|
||||||
- build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
- buildah bud --layers --network host --format oci --platform linux/amd64 --build-arg "WEBDROP_FRONTEND_COMMIT=$build_commit" --build-arg "WEBDROP_FRONTEND_TAG=$release_tag" --build-arg "WEBDROP_BACKEND_COMMIT=$build_commit" --build-arg "WEBDROP_BACKEND_TAG=$release_tag" --build-arg "WEBDROP_BUILD_TIME=$build_time" --tag "$image" --file server/Containerfile .
|
|
||||||
- buildah tag "$image" "$latest_image"
|
|
||||||
- buildah push "$image" "docker://$image"
|
|
||||||
- buildah push "$latest_image" "docker://$latest_image"
|
|
||||||
|
|
||||||
|
publish-files:
|
||||||
|
image: bash
|
||||||
|
depends_on: [release-checks]
|
||||||
|
environment:
|
||||||
|
ZOT_USERNAME: justme
|
||||||
|
ZOT_SECRET:
|
||||||
|
from_secret: zot_secret
|
||||||
|
ZOT_REGISTRY: registry.oeo.one
|
||||||
|
commands:
|
||||||
|
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
|
||||||
|
- bash scripts/ci-publish-image.sh webdrop-files server/Containerfile.files "$release_tag" latest
|
||||||
+159
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"x-backend-contract-sha256": "f4bc49429f3d1be87defcdfa3c94b61822eea9dfcfa6693241572edea1a8aefc",
|
"x-backend-contract-sha256": "89c6298b865197835486f01891acc597802367a7ae1b89f21ba882a52935df3b",
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"AccountChannelBody": {
|
"AccountChannelBody": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
"ChannelInfo": {
|
"ChannelInfo": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"avatar": {
|
"avatar_version": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -322,9 +322,33 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"can_create_channel": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"can_delete_any": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"can_delete_own": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"can_edit_settings": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"can_invite": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"can_manage_folders": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"can_manage_roles": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"can_manage_structure": {
|
"can_manage_structure": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"can_remove_members": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"can_upload": {
|
"can_upload": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -347,6 +371,9 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"is_owner": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"last_seq": {
|
"last_seq": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -367,11 +394,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"can_create_channel",
|
||||||
|
"can_delete_any",
|
||||||
|
"can_delete_own",
|
||||||
|
"can_edit_settings",
|
||||||
|
"can_invite",
|
||||||
|
"can_manage_folders",
|
||||||
|
"can_manage_roles",
|
||||||
"can_manage_structure",
|
"can_manage_structure",
|
||||||
|
"can_remove_members",
|
||||||
"can_upload",
|
"can_upload",
|
||||||
"created_at",
|
"created_at",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
"id",
|
"id",
|
||||||
|
"is_owner",
|
||||||
"last_seq",
|
"last_seq",
|
||||||
"members_used",
|
"members_used",
|
||||||
"mode",
|
"mode",
|
||||||
@@ -844,6 +880,64 @@
|
|||||||
"error"
|
"error"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"FileGrantBody": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"domain": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"ticket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"domain",
|
||||||
|
"expires_at",
|
||||||
|
"ticket",
|
||||||
|
"url"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"FileGrantRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"channel": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"item": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"op": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"from",
|
||||||
|
"op"
|
||||||
|
]
|
||||||
|
},
|
||||||
"FinishShareRequest": {
|
"FinishShareRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -878,6 +972,9 @@
|
|||||||
"InvitationBody": {
|
"InvitationBody": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"can_revoke": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"channel_id": {
|
"channel_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -892,6 +989,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"can_revoke",
|
||||||
"channel_id",
|
"channel_id",
|
||||||
"created_at",
|
"created_at",
|
||||||
"payload",
|
"payload",
|
||||||
@@ -1030,6 +1128,9 @@
|
|||||||
"MemberBody": {
|
"MemberBody": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"can_remove": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"devices": {
|
"devices": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
@@ -1053,6 +1154,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"can_remove",
|
||||||
"devices",
|
"devices",
|
||||||
"joined_at",
|
"joined_at",
|
||||||
"last_seen",
|
"last_seen",
|
||||||
@@ -1354,6 +1456,9 @@
|
|||||||
"defaults": {
|
"defaults": {
|
||||||
"$ref": "#/$defs/DefaultsBody"
|
"$ref": "#/$defs/DefaultsBody"
|
||||||
},
|
},
|
||||||
|
"files": {
|
||||||
|
"$ref": "#/$defs/FilesInfoBody"
|
||||||
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"$ref": "#/$defs/LimitsBody"
|
"$ref": "#/$defs/LimitsBody"
|
||||||
},
|
},
|
||||||
@@ -1369,6 +1474,7 @@
|
|||||||
"choices",
|
"choices",
|
||||||
"contract_version",
|
"contract_version",
|
||||||
"defaults",
|
"defaults",
|
||||||
|
"files",
|
||||||
"limits",
|
"limits",
|
||||||
"protocol",
|
"protocol",
|
||||||
"transports"
|
"transports"
|
||||||
@@ -1418,6 +1524,27 @@
|
|||||||
"share"
|
"share"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"FilesInfoBody": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
"LimitsBody": {
|
"LimitsBody": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1445,9 +1572,6 @@
|
|||||||
"max_workspace_bytes": {
|
"max_workspace_bytes": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"max_workspace_channel_bytes": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"min_ttl_seconds": {
|
"min_ttl_seconds": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
@@ -1461,7 +1585,6 @@
|
|||||||
"max_ttl_seconds",
|
"max_ttl_seconds",
|
||||||
"max_visitors",
|
"max_visitors",
|
||||||
"max_workspace_bytes",
|
"max_workspace_bytes",
|
||||||
"max_workspace_channel_bytes",
|
|
||||||
"min_ttl_seconds"
|
"min_ttl_seconds"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1777,8 +1900,15 @@
|
|||||||
"createdBy": {
|
"createdBy": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"fileBytes": {
|
"fileDomain": {
|
||||||
"type": "integer"
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"filesEpoch": {
|
"filesEpoch": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -1808,7 +1938,6 @@
|
|||||||
"chatHistory",
|
"chatHistory",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"createdBy",
|
"createdBy",
|
||||||
"fileBytes",
|
|
||||||
"filesEpoch",
|
"filesEpoch",
|
||||||
"filesHistory",
|
"filesHistory",
|
||||||
"id",
|
"id",
|
||||||
@@ -1818,6 +1947,16 @@
|
|||||||
"WorkspaceChannelRequest": {
|
"WorkspaceChannelRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"fileDomain": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"groupId": {
|
"groupId": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1836,6 +1975,17 @@
|
|||||||
"name"
|
"name"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"WorkspaceFileDomainRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fileDomain": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fileDomain"
|
||||||
|
]
|
||||||
|
},
|
||||||
"WorkspaceFolderRequest": {
|
"WorkspaceFolderRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ PublishPort=127.0.0.1:5001:5000
|
|||||||
Environment=ASPNETCORE_URLS=http://+:5000
|
Environment=ASPNETCORE_URLS=http://+:5000
|
||||||
Environment=WebDrop__DataDir=/data
|
Environment=WebDrop__DataDir=/data
|
||||||
Environment=DOTNET_EnableDiagnostics=0
|
Environment=DOTNET_EnableDiagnostics=0
|
||||||
|
EnvironmentFile=-%h/podman/webdrop-dev/webdrop.env
|
||||||
Volume=%h/podman/webdrop-dev/data:/data:Z,U
|
Volume=%h/podman/webdrop-dev/data:/data:Z,U
|
||||||
ReadOnly=true
|
ReadOnly=true
|
||||||
ReadOnlyTmpfs=false
|
ReadOnlyTmpfs=false
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=WebDrop file service (fileAPI) development environment from registry
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=webdrop-files-dev
|
||||||
|
Image=registry.oeo.one/justme/webdrop-files:dev
|
||||||
|
Pull=missing
|
||||||
|
AutoUpdate=registry
|
||||||
|
PublishPort=127.0.0.1:5101:5100
|
||||||
|
Environment=ASPNETCORE_URLS=http://+:5100
|
||||||
|
Environment=FileApi__DataDir=/data
|
||||||
|
Environment=DOTNET_EnableDiagnostics=0
|
||||||
|
EnvironmentFile=%h/podman/webdrop-files-dev/webdrop-files.env
|
||||||
|
Volume=%h/podman/webdrop-files-dev/data:/data:Z,U
|
||||||
|
ReadOnly=true
|
||||||
|
ReadOnlyTmpfs=false
|
||||||
|
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
|
||||||
|
DropCapability=ALL
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
TimeoutStartSec=120
|
||||||
|
TimeoutStopSec=60
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=WebDrop file service (fileAPI)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=webdrop-files
|
||||||
|
# 正式环境:跟踪打 v* tag 发布的 latest。放文件的机器(例如家里的 NAS)只拉这个镜像,不需要主后端的
|
||||||
|
Image=registry.oeo.one/justme/webdrop-files:latest
|
||||||
|
Pull=missing
|
||||||
|
AutoUpdate=registry
|
||||||
|
PublishPort=127.0.0.1:5100:5100
|
||||||
|
Environment=ASPNETCORE_URLS=http://+:5100
|
||||||
|
Environment=FileApi__DataDir=/data
|
||||||
|
Environment=DOTNET_EnableDiagnostics=0
|
||||||
|
# 和主后端共用的密钥、允许的页面来源、备份设置等,不进仓库。例子见 webdrop-files.env.example
|
||||||
|
EnvironmentFile=%h/podman/webdrop-files/webdrop-files.env
|
||||||
|
Volume=%h/podman/webdrop-files/data:/data:Z,U
|
||||||
|
ReadOnly=true
|
||||||
|
ReadOnlyTmpfs=false
|
||||||
|
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
|
||||||
|
DropCapability=ALL
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
TimeoutStartSec=120
|
||||||
|
TimeoutStopSec=60
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 文件服务(家里的 NAS)的机器配置:复制到 ~/podman/webdrop-files/webdrop-files.env 并按实际情况修改。不要提交真实值
|
||||||
|
# 和主后端共用的密钥,至少 32 个字符。两边必须一致
|
||||||
|
FileApi__Secret=
|
||||||
|
# 页面所在的源:浏览器直接把文件传到这里,必须列出,否则浏览器会拦下跨域请求
|
||||||
|
FileApi__CorsOrigins__0=https://app.example.com
|
||||||
|
# 创建频道时可选的文件空间档位(字节)和默认值;不配用内置的 1/5/10/50/100 GiB,默认 10 GiB
|
||||||
|
# FileApi__QuotaChoices__0=1073741824
|
||||||
|
# FileApi__QuotaChoices__1=10737418240
|
||||||
|
# FileApi__DefaultQuota=10737418240
|
||||||
|
# 文件服务最多存多少字节,0 为不限
|
||||||
|
# FileApi__MaxStoreBytes=0
|
||||||
|
# 每天把新增的文件备份到 S3 协议的存储
|
||||||
|
# FileApi__Backup__Enabled=true
|
||||||
|
# FileApi__Backup__Time=04:30
|
||||||
|
# FileApi__Backup__S3__Endpoint=https://s3.example.com
|
||||||
|
# FileApi__Backup__S3__Bucket=webdrop
|
||||||
|
# FileApi__Backup__S3__AccessKey=
|
||||||
|
# FileApi__Backup__S3__SecretKey=
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=WebDrop API with embedded frontend
|
Description=WebDrop API with embedded frontend
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName=webdrop
|
ContainerName=webdrop
|
||||||
Image=localhost/webdrop:local
|
# 正式环境:跟踪打 v* tag 发布的 latest。podman-auto-update.timer 每隔几分钟检查 registry,有新镜像就拉取并重启这个单元
|
||||||
Pull=never
|
Image=registry.oeo.one/justme/webdrop-api:latest
|
||||||
|
Pull=missing
|
||||||
|
AutoUpdate=registry
|
||||||
PublishPort=127.0.0.1:5000:5000
|
PublishPort=127.0.0.1:5000:5000
|
||||||
Environment=ASPNETCORE_URLS=http://+:5000
|
Environment=ASPNETCORE_URLS=http://+:5000
|
||||||
Environment=WebDrop__DataDir=/data
|
Environment=WebDrop__DataDir=/data
|
||||||
Environment=DOTNET_EnableDiagnostics=0
|
Environment=DOTNET_EnableDiagnostics=0
|
||||||
Volume=/home/justme/podman/webdrop/data:/data:Z,U
|
# 机器上的配置(文件服务的地址和共用密钥等),不进仓库。例子见 webdrop.env.example
|
||||||
|
EnvironmentFile=-%h/podman/webdrop/webdrop.env
|
||||||
|
Volume=%h/podman/webdrop/data:/data:Z,U
|
||||||
ReadOnly=true
|
ReadOnly=true
|
||||||
ReadOnlyTmpfs=false
|
ReadOnlyTmpfs=false
|
||||||
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
|
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 主后端(VPS)的机器配置:复制到 ~/podman/webdrop/webdrop.env 并按实际情况修改。不要提交真实值
|
||||||
|
# 打开文件服务:文件内容不放主后端,由 fileAPI 保存;主后端只签发访问票据
|
||||||
|
WebDrop__Files__Enabled=true
|
||||||
|
# 浏览器访问文件服务的地址(必须 https,和文件服务机器上配的对外地址一致)
|
||||||
|
WebDrop__Files__PublicUrl=https://files.example.com
|
||||||
|
# 和文件服务共用的密钥,至少 32 个字符:openssl rand -base64 48。两边必须一致
|
||||||
|
WebDrop__Files__Secret=
|
||||||
@@ -9,9 +9,9 @@ const fail = (message) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dtoSources = ['server/src/WebDrop.Api/ApiJson.cs', 'server/src/WebDrop.Core/Dtos.cs', 'server/src/WebDrop.Core/Settings.cs', 'server/src/WebDrop.Core/Enums.cs', 'server/src/WebDrop.Core/Workspace.cs', 'server/src/WebDrop.Core/AccountProfile.cs', 'server/src/WebDrop.Api/PasskeyDtos.cs', 'server/src/WebDrop.Core/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs'];
|
const dtoSources = ['server/src/WebDrop.Api/Json/ApiJson.cs', 'server/src/WebDrop.Core/Channels/Dtos.cs', 'server/src/WebDrop.Core/Channels/Settings.cs', 'server/src/WebDrop.Core/Common/Enums.cs', 'server/src/WebDrop.Core/Spaces/Workspace.cs', 'server/src/WebDrop.Core/Accounts/AccountProfile.cs', 'server/src/WebDrop.Api/Passkeys/PasskeyDtos.cs', 'server/src/WebDrop.Core/Spaces/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints/Endpoints.WorkspaceCrypto.cs'];
|
||||||
const dtoDigest = createHash('sha256').update(dtoSources.map(path => `${path}\n${read(path).replace(/\r\n/g, '\n')}\n`).join('')).digest('hex');
|
const dtoDigest = createHash('sha256').update(dtoSources.map(path => `${path}\n${read(path).replace(/\r\n/g, '\n')}\n`).join('')).digest('hex');
|
||||||
const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/core-bridge/generated/api.ts'))?.[1];
|
const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/api/generated.ts'))?.[1];
|
||||||
if (generatedDigest !== dtoDigest) fail('Backend DTOs changed. Regenerate with dotnet run --project tools/ContractGenerator -- .');
|
if (generatedDigest !== dtoDigest) fail('Backend DTOs changed. Regenerate with dotnet run --project tools/ContractGenerator -- .');
|
||||||
if (JSON.parse(read('contracts/api.schemas.json'))['x-backend-contract-sha256'] !== dtoDigest) fail('Generated API schemas are stale. Run ContractGenerator.');
|
if (JSON.parse(read('contracts/api.schemas.json'))['x-backend-contract-sha256'] !== dtoDigest) fail('Generated API schemas are stale. Run ContractGenerator.');
|
||||||
|
|
||||||
@@ -24,14 +24,14 @@ const openapi = read('docs/openapi.yaml');
|
|||||||
const openapiVersion = /^info:\s*\r?\n(?:.*\r?\n)*?\s+version:\s*([^\s#]+)/m.exec(openapi)?.[1];
|
const openapiVersion = /^info:\s*\r?\n(?:.*\r?\n)*?\s+version:\s*([^\s#]+)/m.exec(openapi)?.[1];
|
||||||
if (openapiVersion !== contract.version) fail(`OpenAPI version ${openapiVersion ?? '(missing)'} differs from ${contract.version}.`);
|
if (openapiVersion !== contract.version) fail(`OpenAPI version ${openapiVersion ?? '(missing)'} differs from ${contract.version}.`);
|
||||||
|
|
||||||
const backend = read('server/src/WebDrop.Api/ApiContract.cs');
|
const backend = read('server/src/WebDrop.Api/Hosting/ApiContract.cs');
|
||||||
const backendVersion = /const string Version = "([^"]+)"/.exec(backend)?.[1];
|
const backendVersion = /const string Version = "([^"]+)"/.exec(backend)?.[1];
|
||||||
if (backendVersion !== contract.version) fail(`backend version ${backendVersion ?? '(missing)'} differs from ${contract.version}.`);
|
if (backendVersion !== contract.version) fail(`backend version ${backendVersion ?? '(missing)'} differs from ${contract.version}.`);
|
||||||
|
|
||||||
const frontend = read('web/src/core-bridge/contract.ts');
|
const frontend = read('web/src/api/contract.ts');
|
||||||
const supportedMajor = Number(/SUPPORTED_API_CONTRACT_MAJOR\s*=\s*(\d+)/.exec(frontend)?.[1]);
|
const supportedMajor = Number(/SUPPORTED_API_CONTRACT_MAJOR\s*=\s*(\d+)/.exec(frontend)?.[1]);
|
||||||
if (supportedMajor !== apiMajor) {
|
if (supportedMajor !== apiMajor) {
|
||||||
fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/core-bridge/contract.ts.`);
|
fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/api/contract.ts.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tag = process.argv[2];
|
const tag = process.argv[2];
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 构建并推送一个镜像到 registry。开发镜像和发布镜像、主后端和文件服务都用它,构建和推送的细节只写在这里。
|
||||||
|
# 用法: ci-publish-image.sh <镜像名> <Containerfile> <版本标签> [额外标签...]
|
||||||
|
# 例: ci-publish-image.sh webdrop-api server/Containerfile dev-abc1234 dev
|
||||||
|
# 需要环境变量: ZOT_REGISTRY、ZOT_USERNAME、ZOT_SECRET(Woodpecker 的密钥)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
name="$1"; containerfile="$2"; version="$3"; shift 3
|
||||||
|
: "${ZOT_REGISTRY:?}" "${ZOT_USERNAME:?}" "${ZOT_SECRET:?}"
|
||||||
|
: "${WOODPECKER_BACKEND_LOCAL_TEMP_DIR:=$HOME/woodpecker/work}"
|
||||||
|
|
||||||
|
# 每个镜像一个构建缓存目录,两个镜像并行构建时互不干扰
|
||||||
|
store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/$name"
|
||||||
|
mkdir -p "$store/storage" "$store/run"
|
||||||
|
buildah() { command buildah --root "$store/storage" --runroot "$store/run" "$@"; }
|
||||||
|
|
||||||
|
printf '%s' "$ZOT_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
|
||||||
|
trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
|
||||||
|
|
||||||
|
commit="${CI_COMMIT_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
image="$ZOT_REGISTRY/justme/$name:$version"
|
||||||
|
buildah bud --layers --network host --format oci --platform linux/amd64 \
|
||||||
|
--build-arg "WEBDROP_FRONTEND_COMMIT=$commit" --build-arg "WEBDROP_FRONTEND_TAG=$version" \
|
||||||
|
--build-arg "WEBDROP_BACKEND_COMMIT=$commit" --build-arg "WEBDROP_BACKEND_TAG=$version" \
|
||||||
|
--build-arg "WEBDROP_BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--tag "$image" --file "$containerfile" .
|
||||||
|
|
||||||
|
buildah push "$image" "docker://$image"
|
||||||
|
for extra in "$@"; do
|
||||||
|
buildah tag "$image" "$ZOT_REGISTRY/justme/$name:$extra"
|
||||||
|
buildah push "$ZOT_REGISTRY/justme/$name:$extra" "docker://$ZOT_REGISTRY/justme/$name:$extra"
|
||||||
|
done
|
||||||
@@ -22,8 +22,10 @@ WORKDIR /src
|
|||||||
COPY server/Directory.Build.props ./server/
|
COPY server/Directory.Build.props ./server/
|
||||||
COPY server/src/WebDrop.Api/WebDrop.Api.csproj ./server/src/WebDrop.Api/
|
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.Core/WebDrop.Core.csproj ./server/src/WebDrop.Core/
|
||||||
|
COPY server/src/WebDrop.FileProtocol/WebDrop.FileProtocol.csproj ./server/src/WebDrop.FileProtocol/
|
||||||
COPY server/src/WebDrop.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/
|
COPY server/src/WebDrop.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/
|
||||||
COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/
|
COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/
|
||||||
|
COPY server/src/WebDrop.Sync/WebDrop.Sync.csproj ./server/src/WebDrop.Sync/
|
||||||
RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \
|
RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \
|
||||||
--runtime linux-x64
|
--runtime linux-x64
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 文件服务(fileAPI):单独部署,放在存储所在的机器(例如家里的 NAS)。只依赖 FileProtocol 和 Sync,不带前端和数据库
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY server/Directory.Build.props ./server/
|
||||||
|
COPY server/src/WebDrop.FileApi/WebDrop.FileApi.csproj ./server/src/WebDrop.FileApi/
|
||||||
|
COPY server/src/WebDrop.FileProtocol/WebDrop.FileProtocol.csproj ./server/src/WebDrop.FileProtocol/
|
||||||
|
COPY server/src/WebDrop.Sync/WebDrop.Sync.csproj ./server/src/WebDrop.Sync/
|
||||||
|
RUN dotnet restore server/src/WebDrop.FileApi/WebDrop.FileApi.csproj \
|
||||||
|
--runtime linux-x64
|
||||||
|
COPY server/src/WebDrop.FileApi/ ./server/src/WebDrop.FileApi/
|
||||||
|
COPY server/src/WebDrop.FileProtocol/ ./server/src/WebDrop.FileProtocol/
|
||||||
|
COPY server/src/WebDrop.Sync/ ./server/src/WebDrop.Sync/
|
||||||
|
RUN dotnet publish server/src/WebDrop.FileApi/WebDrop.FileApi.csproj \
|
||||||
|
--configuration Release \
|
||||||
|
--runtime linux-x64 \
|
||||||
|
--self-contained true \
|
||||||
|
--no-restore \
|
||||||
|
--output /out
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
|
||||||
|
ARG WEBDROP_BACKEND_COMMIT=unknown
|
||||||
|
ARG WEBDROP_BACKEND_TAG=unknown
|
||||||
|
ARG WEBDROP_BUILD_TIME=unknown
|
||||||
|
LABEL org.opencontainers.image.revision=$WEBDROP_BACKEND_COMMIT \
|
||||||
|
org.opencontainers.image.version=$WEBDROP_BACKEND_TAG \
|
||||||
|
org.opencontainers.image.created=$WEBDROP_BUILD_TIME
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out/webdrop-files /app/webdrop-files
|
||||||
|
RUN mkdir -p /data \
|
||||||
|
&& chown 10001:10001 /data \
|
||||||
|
&& chmod 0750 /data
|
||||||
|
ENV ASPNETCORE_URLS=http://+:5100 \
|
||||||
|
FileApi__DataDir=/data \
|
||||||
|
DOTNET_EnableDiagnostics=0
|
||||||
|
USER 10001:10001
|
||||||
|
EXPOSE 5100
|
||||||
|
ENTRYPOINT ["/app/webdrop-files"]
|
||||||
+12
-2
@@ -1,9 +1,19 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<!-- 主后端(hostAPI):账号、空间、频道、聊天、实时推送,以及内嵌的前端 -->
|
||||||
|
<Folder Name="/src/host/">
|
||||||
<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.Frontend/WebDrop.Frontend.csproj" />
|
<Project Path="src/WebDrop.Frontend/WebDrop.Frontend.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<!-- 文件服务(fileAPI):另一个进程,只保存超过阈值的大文件 -->
|
||||||
|
<Folder Name="/src/files/">
|
||||||
|
<Project Path="src/WebDrop.FileApi/WebDrop.FileApi.csproj" />
|
||||||
|
<Project Path="src/WebDrop.FileProtocol/WebDrop.FileProtocol.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<!-- 两边共用:业务规则、数据库、S3 备份 -->
|
||||||
|
<Folder Name="/src/shared/">
|
||||||
|
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
|
||||||
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
|
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
|
||||||
|
<Project Path="src/WebDrop.Sync/WebDrop.Sync.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/WebDrop.Api.Tests/WebDrop.Api.Tests.csproj" />
|
<Project Path="tests/WebDrop.Api.Tests/WebDrop.Api.Tests.csproj" />
|
||||||
|
|||||||
+1
-3
@@ -19,9 +19,7 @@ public static partial class Endpoints
|
|||||||
if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired);
|
if (!TryAccount(ctx, accounts, out var viewer)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||||
// 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取
|
// 只有和对方同在一个空间(或就是自己)才能看头像,不能靠猜用户名批量抓取
|
||||||
if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound);
|
if (!svc.SharesSpace(viewer, NormalizeUsername(username))) throw new WebDropException(ErrorCode.NotFound);
|
||||||
var profile = accounts.Profile(NormalizeUsername(username));
|
return AvatarResult(ctx, accounts.ProfileAvatar(NormalizeUsername(username), ctx.Request.Query["size"] == "full"));
|
||||||
var full = ctx.Request.Query["size"] == "full";
|
|
||||||
return AvatarResult(ctx, full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar, $"{profile.ModifiedAt}-{(full ? "f" : "t")}");
|
|
||||||
});
|
});
|
||||||
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
+5
-3
@@ -78,7 +78,8 @@ public static partial class Endpoints
|
|||||||
return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody);
|
return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody);
|
||||||
});
|
});
|
||||||
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
|
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
|
||||||
Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody));
|
Json(svc.InvitationsView(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)).Select(view => new InvitationBody
|
||||||
|
{ ChannelId = view.Invitation.ChannelId.ToBase64Url(), Username = view.Invitation.Username, Payload = view.Invitation.Payload, CreatedAt = view.Invitation.CreatedAt, CanRevoke = view.CanRevoke }).ToList(), ApiJson.Default.ListInvitationBody));
|
||||||
api.MapGet("/channels/{id}/invitations/{username}/crypto-plan", (string id, string username, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
|
api.MapGet("/channels/{id}/invitations/{username}/crypto-plan", (string id, string username, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
|
||||||
{
|
{
|
||||||
username = NormalizeUsername(username);
|
username = NormalizeUsername(username);
|
||||||
@@ -88,14 +89,15 @@ public static partial class Endpoints
|
|||||||
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
|
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
|
||||||
{
|
{
|
||||||
var channelId = ParseId(id);
|
var channelId = ParseId(id);
|
||||||
var members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, false)).Select(member =>
|
var members = svc.MembersView(channelId, RequestAuth.FromHeaders(ctx.Request, false)).Select(view =>
|
||||||
{
|
{
|
||||||
|
var member = view.Member;
|
||||||
var (online, onlineDevices) = hub.Presence(channelId, member.Username);
|
var (online, onlineDevices) = hub.Presence(channelId, member.Username);
|
||||||
// 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备
|
// 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备
|
||||||
var devices = accounts.Devices(member.Username, null);
|
var devices = accounts.Devices(member.Username, null);
|
||||||
return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt,
|
return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt,
|
||||||
Online = online, Devices = Math.Max(devices.Count, onlineDevices), OnlineDevices = onlineDevices,
|
Online = online, Devices = Math.Max(devices.Count, onlineDevices), OnlineDevices = onlineDevices,
|
||||||
LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen) };
|
LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen), CanRemove = view.CanRemove };
|
||||||
}).ToList();
|
}).ToList();
|
||||||
return Json(members, ApiJson.Default.ListMemberBody);
|
return Json(members, ApiJson.Default.ListMemberBody);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using WebDrop.Core;
|
||||||
|
using WebDrop.Storage;
|
||||||
|
|
||||||
|
namespace WebDrop.Api;
|
||||||
|
|
||||||
|
public static partial class Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 文件服务的票据:权限由空间决定,通过后给客户端一张短期票据,客户端拿着它直接找文件服务。
|
||||||
|
/// create 是所有者/管理员创建文件域;put/get 是按频道权限上传、下载某个条目。
|
||||||
|
/// </summary>
|
||||||
|
private static void MapFiles(RouteGroupBuilder api)
|
||||||
|
{
|
||||||
|
// 一次登记整个文件:请求体 = 末帧的 26 字节头部 + 每一帧的 [负载长度 u32 大端][整帧 SHA-256 32 字节]。
|
||||||
|
// 字节早已存进文件域;逐帧登记要为每个 1 MiB 往返一次,这里一次搞定
|
||||||
|
api.MapPost("/channels/{id}/files", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var channelId = ParseId(id);
|
||||||
|
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
|
||||||
|
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
|
||||||
|
const int EntrySize = 36;
|
||||||
|
int max = Envelope.HeaderSize + limits.MaxFramesPerItem * EntrySize;
|
||||||
|
if (ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = max;
|
||||||
|
byte[] body = await ReadBody(ctx.Request, max, ct);
|
||||||
|
if (body.Length < Envelope.HeaderSize + EntrySize || (body.Length - Envelope.HeaderSize) % EntrySize != 0) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
var header = body.AsMemory(0, Envelope.HeaderSize);
|
||||||
|
var entries = new List<ChannelService.FrameEntry>((body.Length - Envelope.HeaderSize) / EntrySize);
|
||||||
|
for (int at = Envelope.HeaderSize; at < body.Length; at += EntrySize)
|
||||||
|
entries.Add(new ChannelService.FrameEntry((int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(body.AsSpan(at, 4)), body.AsSpan(at + 4, 32).ToArray()));
|
||||||
|
var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, header.ToArray());
|
||||||
|
var result = svc.RegisterFileItem(channelId, creds, header, entries, maxDownloads, signedEvent);
|
||||||
|
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/spaces/{id}/file-grants", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var spaceId = ParseId(id);
|
||||||
|
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
|
||||||
|
var req = await ReadJson(ctx.Request, ApiJson.Default.FileGrantRequest, ct);
|
||||||
|
FileGrant grant;
|
||||||
|
if (req.Op == "create") grant = svc.IssueCreateGrant(spaceId, creds);
|
||||||
|
else if (req.Op == "domain") grant = svc.IssueDomainGrant(spaceId, creds, req.Channel ?? throw new WebDropException(ErrorCode.BadRequest));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (req.Item is null || req.From is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
var item = ParseId(req.Item);
|
||||||
|
grant = req.Op switch
|
||||||
|
{
|
||||||
|
"put" => svc.IssueUploadGrant(spaceId, creds, item),
|
||||||
|
"get" => svc.IssueDownloadGrant(spaceId, creds, item, (uint)req.From),
|
||||||
|
"delete" => svc.IssueDeleteGrant(spaceId, creds, item),
|
||||||
|
_ => throw new WebDropException(ErrorCode.BadRequest),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Json(new FileGrantBody { Url = grant.Url, Domain = grant.Domain, Ticket = grant.Ticket, ExpiresAt = grant.ExpiresAt }, ApiJson.Default.FileGrantBody);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
+6
-1
@@ -20,7 +20,7 @@ public static partial class Endpoints
|
|||||||
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
|
||||||
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId);
|
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId, req.FileDomain);
|
||||||
return Json(new WorkspaceChannelCreatedBody { Workspace = View(svc, ParseId(id), created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
|
return Json(new WorkspaceChannelCreatedBody { Workspace = View(svc, ParseId(id), created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
|
||||||
});
|
});
|
||||||
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
@@ -28,6 +28,11 @@ public static partial class Endpoints
|
|||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||||
return Json(View(svc, ParseId(id), svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
return Json(View(svc, ParseId(id), svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||||
});
|
});
|
||||||
|
api.MapPut("/spaces/{id}/channels/{channelId}/file-domain", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFileDomainRequest, ct);
|
||||||
|
return Json(View(svc, ParseId(id), svc.SetWorkspaceChannelFileDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.FileDomain)), ApiJson.Default.WorkspaceBody);
|
||||||
|
});
|
||||||
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
|
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
|
||||||
File renamed without changes.
+28
-14
@@ -17,7 +17,7 @@ public static partial class Endpoints
|
|||||||
private const string OctetStream = "application/octet-stream";
|
private const string OctetStream = "application/octet-stream";
|
||||||
private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25);
|
private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25);
|
||||||
|
|
||||||
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts)
|
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts, IFileGrants files)
|
||||||
{
|
{
|
||||||
var api = app.MapGroup("/api/v1");
|
var api = app.MapGroup("/api/v1");
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ public static partial class Endpoints
|
|||||||
},
|
},
|
||||||
Transports = new TransportsBody { Http3 = options.EnableHttp3 },
|
Transports = new TransportsBody { Http3 = options.EnableHttp3 },
|
||||||
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
|
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
|
||||||
|
Files = new FilesInfoBody { Enabled = files.Enabled, Url = files.Enabled ? files.PublicUrl : null },
|
||||||
Choices = new SettingChoicesBody
|
Choices = new SettingChoicesBody
|
||||||
{
|
{
|
||||||
MaxMembers = new[] { 2, 3, 5, 10, limits.MaxMembers }.Where(n => n >= 1 && n <= limits.MaxMembers).Distinct().Order().ToArray(),
|
MaxMembers = new[] { 2, 3, 5, 10, limits.MaxMembers }.Where(n => n >= 1 && n <= limits.MaxMembers).Distinct().Order().ToArray(),
|
||||||
@@ -44,7 +45,6 @@ public static partial class Endpoints
|
|||||||
MaxFramesPerItem = limits.MaxFramesPerItem,
|
MaxFramesPerItem = limits.MaxFramesPerItem,
|
||||||
MaxChannelBytes = limits.MaxChannelBytes,
|
MaxChannelBytes = limits.MaxChannelBytes,
|
||||||
MaxWorkspaceBytes = limits.MaxWorkspaceBytes,
|
MaxWorkspaceBytes = limits.MaxWorkspaceBytes,
|
||||||
MaxWorkspaceChannelBytes = limits.MaxWorkspaceChannelBytes,
|
|
||||||
MaxMembers = limits.MaxMembers,
|
MaxMembers = limits.MaxMembers,
|
||||||
MaxTtlSeconds = limits.MaxTtlSeconds,
|
MaxTtlSeconds = limits.MaxTtlSeconds,
|
||||||
MinTtlSeconds = limits.MinTtlSeconds,
|
MinTtlSeconds = limits.MinTtlSeconds,
|
||||||
@@ -57,6 +57,7 @@ public static partial class Endpoints
|
|||||||
MapPasskeys(api, accounts);
|
MapPasskeys(api, accounts);
|
||||||
MapWorkspace(api, accounts);
|
MapWorkspace(api, accounts);
|
||||||
MapWorkspaceCrypto(api);
|
MapWorkspaceCrypto(api);
|
||||||
|
MapFiles(api);
|
||||||
|
|
||||||
// ───────── 频道 ─────────
|
// ───────── 频道 ─────────
|
||||||
|
|
||||||
@@ -81,11 +82,11 @@ public static partial class Endpoints
|
|||||||
return Json(info, ApiJson.Default.ChannelInfo);
|
return Json(info, ApiJson.Default.ChannelInfo);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 空间头像原图(信息里只带缩略图):预览时才下载。带 ETag(内容哈希),没变时回 304
|
// 空间头像(信息里只带版本):默认缩略图,size=full 原图。磁盘文件直接发送,带 ETag(内容哈希),没变时回 304
|
||||||
api.MapGet("/channels/{id}/avatar", (string id, HttpContext ctx, ChannelService svc) =>
|
api.MapGet("/channels/{id}/avatar", (string id, HttpContext ctx, ChannelService svc) =>
|
||||||
{
|
{
|
||||||
var data = svc.GetChannelAvatarFull(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
|
bool thumb = ctx.Request.Query["size"] != "full";
|
||||||
return AvatarResult(ctx, data, Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(data ?? ""))));
|
return AvatarResult(ctx, svc.GetChannelAvatarBlob(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), thumb));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.MapDelete("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
api.MapDelete("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
||||||
@@ -365,12 +366,12 @@ public static partial class Endpoints
|
|||||||
bool any = values.Any(value => value.Length > 0);
|
bool any = values.Any(value => value.Length > 0);
|
||||||
if (!any)
|
if (!any)
|
||||||
{
|
{
|
||||||
if (Envelope.TryParse(frame, out var legacyEnvelope) && legacyEnvelope.IsFinal && creds.AccountName is not null
|
if ((Envelope.TryParse(frame, out var legacyEnvelope) || Envelope.TryParseHeader(frame, out legacyEnvelope)) && legacyEnvelope.IsFinal && creds.AccountName is not null
|
||||||
&& creds.AccountDeviceId is not null && accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) is not null)
|
&& creds.AccountDeviceId is not null && accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) is not null)
|
||||||
throw new WebDropException(ErrorCode.Forbidden);
|
throw new WebDropException(ErrorCode.Forbidden);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!Envelope.TryParse(frame, out var envelope) || !envelope.IsFinal || creds.AccountName is null || creds.AccountDeviceId is null
|
if (!(Envelope.TryParse(frame, out var envelope) || Envelope.TryParseHeader(frame, out envelope)) || !envelope.IsFinal || creds.AccountName is null || creds.AccountDeviceId is null
|
||||||
|| values.Take(8).Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
|
|| values.Take(8).Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|
||||||
|| !int.TryParse(values[4], out var epoch) || epoch < 0
|
|| !int.TryParse(values[4], out var epoch) || epoch < 0
|
||||||
@@ -485,16 +486,15 @@ public static partial class Endpoints
|
|||||||
catch (Exception error) when (error is JsonException or InvalidDataException) { throw new WebDropException(ErrorCode.BadRequest); }
|
catch (Exception error) when (error is JsonException or InvalidDataException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>data URL 头像 → 图片字节(带 ETag,没变时 304)</summary>
|
/// <summary>头像文件(带 ETag = 内容哈希,没变时 304)。直接按路径发送,不读进内存</summary>
|
||||||
private static IResult AvatarResult(HttpContext ctx, string? data, string version)
|
private static IResult AvatarResult(HttpContext ctx, AvatarBlob? blob)
|
||||||
{
|
{
|
||||||
if (data is null) return Results.NotFound();
|
if (blob is null) return Results.NotFound();
|
||||||
var etag = $"\"{version}\"";
|
var etag = $"\"{blob.Hash}\"";
|
||||||
ctx.Response.Headers.ETag = etag;
|
ctx.Response.Headers.ETag = etag;
|
||||||
ctx.Response.Headers.CacheControl = "private, no-cache";
|
ctx.Response.Headers.CacheControl = "private, no-cache";
|
||||||
if (ctx.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(StatusCodes.Status304NotModified);
|
if (ctx.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||||
var mime = data[5..data.IndexOf(';')];
|
return Results.File(blob.Path, blob.Mime);
|
||||||
return Results.Bytes(Convert.FromBase64String(data[(data.IndexOf(',') + 1)..]), mime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<T> ReadJson<T>(HttpRequest req, JsonTypeInfo<T> info, CancellationToken ct) where T : class
|
private static async Task<T> ReadJson<T>(HttpRequest req, JsonTypeInfo<T> info, CancellationToken ct) where T : class
|
||||||
@@ -514,7 +514,21 @@ public static partial class Endpoints
|
|||||||
{
|
{
|
||||||
if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge);
|
if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge);
|
||||||
|
|
||||||
using var ms = new MemoryStream(req.ContentLength is { } len ? (int)len : 1024);
|
// 长度已知:直接读进刚好大小的数组,少一份拷贝(帧最大几 MB,多一份就是一次大对象分配)
|
||||||
|
if (req.ContentLength is { } known)
|
||||||
|
{
|
||||||
|
var exact = new byte[(int)known];
|
||||||
|
int filled = 0;
|
||||||
|
while (filled < exact.Length)
|
||||||
|
{
|
||||||
|
int got = await req.Body.ReadAsync(exact.AsMemory(filled), ct);
|
||||||
|
if (got == 0) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
filled += got;
|
||||||
|
}
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ms = new MemoryStream(1024);
|
||||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(16 * 1024);
|
byte[] buffer = ArrayPool<byte>.Shared.Rent(16 * 1024);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text;
|
||||||
|
using WebDrop.Core;
|
||||||
|
using WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
namespace WebDrop.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主后端这一侧和文件服务的全部关系:拿共用的密钥给客户端签短期票据。
|
||||||
|
/// 它不调用文件服务、不保存文件服务的任何状态;文件域怎么创建、多大、用了多少,都是客户端直接和文件服务之间的事。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileGrants(FileApiOptions options) : IFileGrants
|
||||||
|
{
|
||||||
|
private readonly byte[] _secret = Encoding.UTF8.GetBytes(options.Secret);
|
||||||
|
private readonly string _publicUrl = options.PublicUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
public bool Enabled => true;
|
||||||
|
public string PublicUrl => _publicUrl;
|
||||||
|
|
||||||
|
public FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs)
|
||||||
|
{
|
||||||
|
long expires = nowMs + Math.Max(30, options.TicketSeconds) * 1000L;
|
||||||
|
return new FileGrant(_publicUrl, op == FileOp.Create ? "" : scope, FileTokens.Sign(_secret, new FileTicket(op, scope, item, from, expires, space)), expires);
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,79 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Globalization;
|
||||||
|
using WebDrop.Storage;
|
||||||
|
using WebDrop.Sync;
|
||||||
|
|
||||||
|
namespace WebDrop.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每天把数据库快照(聊天、头像、账号)压缩后上传到外部存储,只在本地留一个临时文件。
|
||||||
|
/// 外部存储里按日期留 RetainDays 份:用户删掉的聊天,最多再在备份里留这么久。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore store, ILogger<BackupWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(30) };
|
||||||
|
private const string Prefix = "webdrop-";
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var backup = options.Backup;
|
||||||
|
if (!backup.Enabled || !backup.S3.Configured) return;
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(DailySchedule.Next(DateTime.Now, backup.TimeOfDay) - DateTime.Now, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
|
||||||
|
try { await RunOnceAsync(DateTime.Now, stoppingToken).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||||
|
catch (Exception e) { logger.LogWarning("数据库备份失败:{Type}", e.GetType().Name); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(string Name, int Purged)> RunOnceAsync(DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var backup = options.Backup;
|
||||||
|
var s3 = new S3Client(_http, backup.S3);
|
||||||
|
string tmp = Path.Combine(options.DataDir, "v2", "backup-tmp");
|
||||||
|
Directory.CreateDirectory(tmp);
|
||||||
|
string name = $"{Prefix}{now:yyyyMMdd-HHmm}.db.gz";
|
||||||
|
string db = Path.Combine(tmp, $"snapshot-{Guid.NewGuid():N}.db"), gz = db + ".gz";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
store.Snapshot(db);
|
||||||
|
await using (var source = File.OpenRead(db))
|
||||||
|
await using (var target = File.Create(gz))
|
||||||
|
await using (var zip = new GZipStream(target, CompressionLevel.SmallestSize))
|
||||||
|
await source.CopyToAsync(zip, ct).ConfigureAwait(false);
|
||||||
|
await using (var upload = File.OpenRead(gz))
|
||||||
|
await s3.PutAsync($"chat/{name}", upload, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var path in new[] { db, gz }) try { File.Delete(path); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像是按内容哈希命名的磁盘文件(不在数据库里):内容不会变,已经上传过的跳过
|
||||||
|
foreach (var kind in new[] { "space", "account" })
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(options.DataDir, "v2", "avatars", kind);
|
||||||
|
if (!Directory.Exists(directory)) continue;
|
||||||
|
var uploaded = (await s3.ListAsync($"chat/avatars/{kind}", ct).ConfigureAwait(false)).ToHashSet();
|
||||||
|
foreach (var file in Directory.EnumerateFiles(directory))
|
||||||
|
{
|
||||||
|
if (file.EndsWith(".tmp", StringComparison.Ordinal) || uploaded.Contains(Path.GetFileName(file))) continue;
|
||||||
|
await using var content = File.OpenRead(file);
|
||||||
|
await s3.PutAsync($"chat/avatars/{kind}/{Path.GetFileName(file)}", content, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留期:按文件名里的日期清理更早的
|
||||||
|
int purged = 0;
|
||||||
|
var cutoff = now.Date.AddDays(-Math.Max(0, backup.RetainDays));
|
||||||
|
foreach (var existing in await s3.ListAsync("chat", ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (!existing.StartsWith(Prefix, StringComparison.Ordinal) || existing.Length < Prefix.Length + 8) continue;
|
||||||
|
if (!DateTime.TryParseExact(existing.AsSpan(Prefix.Length, 8), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue;
|
||||||
|
if (await s3.DeleteAsync($"chat/{existing}", ct).ConfigureAwait(false)) purged++;
|
||||||
|
}
|
||||||
|
if (purged > 0) logger.LogInformation("清理了 {Count} 份过期的数据库备份", purged);
|
||||||
|
return (name, purged);
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+10
-3
@@ -50,7 +50,14 @@ public static class WebDropApp
|
|||||||
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
|
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
|
||||||
builder.Services.AddSingleton<ChannelHub>();
|
builder.Services.AddSingleton<ChannelHub>();
|
||||||
builder.Services.AddSingleton<IChannelEvents>(sp => sp.GetRequiredService<ChannelHub>());
|
builder.Services.AddSingleton<IChannelEvents>(sp => sp.GetRequiredService<ChannelHub>());
|
||||||
builder.Services.AddSingleton<ChannelService>();
|
options.Files.Validate();
|
||||||
|
builder.Services.AddSingleton<IFileGrants>(_ => options.Files.Enabled ? new FileGrants(options.Files) : NoFileGrants.Instance);
|
||||||
|
builder.Services.AddSingleton<BackupWorker>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
|
||||||
|
builder.Services.AddSingleton<ChannelService>(sp => new ChannelService(
|
||||||
|
sp.GetRequiredService<IResourceStore>(),
|
||||||
|
sp.GetRequiredService<IBlobStore>(),
|
||||||
|
sp.GetRequiredService<IClock>(), options.Limits, sp.GetRequiredService<IChannelEvents>(), sp.GetRequiredService<IFileGrants>()));
|
||||||
builder.Services.AddSingleton<ShareService>(sp => new ShareService(
|
builder.Services.AddSingleton<ShareService>(sp => new ShareService(
|
||||||
sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(),
|
sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(),
|
||||||
options.Limits, options.ShareDraftTtlSeconds));
|
options.Limits, options.ShareDraftTtlSeconds));
|
||||||
@@ -67,7 +74,7 @@ public static class WebDropApp
|
|||||||
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
|
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
|
||||||
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account",
|
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account",
|
||||||
"X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
|
"X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
|
||||||
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target")
|
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target", "X-WD-Receipt")
|
||||||
.WithExposedHeaders("X-WD-Last-Seq", "Retry-After")
|
.WithExposedHeaders("X-WD-Last-Seq", "Retry-After")
|
||||||
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
|
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
|
||||||
}
|
}
|
||||||
@@ -113,7 +120,7 @@ public static class WebDropApp
|
|||||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
|
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
|
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
|
||||||
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>());
|
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>(), app.Services.GetRequiredService<IFileGrants>());
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
+33
@@ -1,4 +1,5 @@
|
|||||||
using WebDrop.Core;
|
using WebDrop.Core;
|
||||||
|
using WebDrop.Sync;
|
||||||
|
|
||||||
namespace WebDrop.Api;
|
namespace WebDrop.Api;
|
||||||
|
|
||||||
@@ -33,6 +34,38 @@ public sealed class WebDropOptions
|
|||||||
public ServerLimits Limits { get; set; } = new();
|
public ServerLimits Limits { get; set; } = new();
|
||||||
|
|
||||||
public CaptchaOptions Captcha { get; set; } = new();
|
public CaptchaOptions Captcha { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>文件服务(fileAPI)。关闭时文件帧仍存在本进程的存储里。</summary>
|
||||||
|
public FileApiOptions Files { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>每天把数据库(聊天、头像、账号)做个快照,压缩后上传到外部存储(S3 协议),保留 RetainDays 天</summary>
|
||||||
|
public DailyBackupOptions Backup { get; set; } = new() { Time = "04:15" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件的内容不放主后端,由另一台机器上的文件服务(fileAPI)保存。主后端只保存聊天、头像、空间结构和文件的元数据与摘要,
|
||||||
|
/// 并按空间里的权限给客户端签发访问文件服务的短期票据;客户端直接和文件服务通信,主后端不调用它。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileApiOptions
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>浏览器访问文件服务的地址(经反向隧道对外的那个),形如 https://files.example.com</summary>
|
||||||
|
public string PublicUrl { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>和文件服务共用的密钥,至少 32 个字符。用来给票据签名</summary>
|
||||||
|
public string Secret { get; set; } = "";
|
||||||
|
|
||||||
|
public int TicketSeconds { get; set; } = 600;
|
||||||
|
|
||||||
|
|
||||||
|
public string Validate()
|
||||||
|
{
|
||||||
|
if (!Enabled) return "";
|
||||||
|
if (!Uri.TryCreate(PublicUrl, UriKind.Absolute, out var pub) || pub.Scheme is not ("http" or "https")) throw new InvalidOperationException("WebDrop:Files:PublicUrl 必须是 http(s) 绝对地址");
|
||||||
|
if (Secret.Length < 32) throw new InvalidOperationException("WebDrop:Files:Secret 至少 32 个字符");
|
||||||
|
return pub.GetLeftPart(UriPartial.Authority).TrimEnd('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -23,6 +23,36 @@ public sealed class ServerInfoBody
|
|||||||
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
|
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
|
||||||
[JsonPropertyName("defaults")] public required DefaultsBody Defaults { get; init; }
|
[JsonPropertyName("defaults")] public required DefaultsBody Defaults { get; init; }
|
||||||
[JsonPropertyName("choices")] public required SettingChoicesBody Choices { get; init; }
|
[JsonPropertyName("choices")] public required SettingChoicesBody Choices { get; init; }
|
||||||
|
[JsonPropertyName("files")] public required FilesInfoBody Files { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FilesInfoBody
|
||||||
|
{
|
||||||
|
/// <summary>true 时,超过 threshold_bytes 的文件由客户端直接传给文件服务,主后端只登记元数据</summary>
|
||||||
|
[JsonPropertyName("enabled")] public bool Enabled { get; init; }
|
||||||
|
/// <summary>超过这个大小的文件才存到文件服务;未开启时为 0</summary>
|
||||||
|
[JsonPropertyName("url")] public string? Url { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FileGrantRequest
|
||||||
|
{
|
||||||
|
/// <summary>create:创建文件域(空间所有者/管理员);put:上传某个条目;get:下载或播放某个条目;delete:删除某个条目的字节;domain:查询某个频道文件域的容量和用量</summary>
|
||||||
|
[JsonPropertyName("op")] public required string Op { get; init; }
|
||||||
|
/// <summary>put/get/delete 时的条目 id(base64url)</summary>
|
||||||
|
[JsonPropertyName("item")] public string? Item { get; init; }
|
||||||
|
/// <summary>domain 时的频道 id:查询这个频道文件域的容量和用量</summary>
|
||||||
|
[JsonPropertyName("channel")] public string? Channel { get; init; }
|
||||||
|
/// <summary>get 时从第几帧开始(0 才计一次下载)</summary>
|
||||||
|
[JsonPropertyName("from")] public long From { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>去文件服务用的凭证:地址、文件域 id(create 时为空)和一张短期票据</summary>
|
||||||
|
public sealed class FileGrantBody
|
||||||
|
{
|
||||||
|
[JsonPropertyName("url")] public required string Url { get; init; }
|
||||||
|
[JsonPropertyName("domain")] public required string Domain { get; init; }
|
||||||
|
[JsonPropertyName("ticket")] public required string Ticket { get; init; }
|
||||||
|
[JsonPropertyName("expires_at")] public long ExpiresAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DefaultsBody
|
public sealed class DefaultsBody
|
||||||
@@ -60,7 +90,6 @@ public sealed class LimitsBody
|
|||||||
[JsonPropertyName("max_frames_per_item")] public required int MaxFramesPerItem { get; init; }
|
[JsonPropertyName("max_frames_per_item")] public required int MaxFramesPerItem { get; init; }
|
||||||
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
|
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
|
||||||
[JsonPropertyName("max_workspace_bytes")] public required long MaxWorkspaceBytes { get; init; }
|
[JsonPropertyName("max_workspace_bytes")] public required long MaxWorkspaceBytes { get; init; }
|
||||||
[JsonPropertyName("max_workspace_channel_bytes")] public required long MaxWorkspaceChannelBytes { get; init; }
|
|
||||||
[JsonPropertyName("max_members")] public required int MaxMembers { get; init; }
|
[JsonPropertyName("max_members")] public required int MaxMembers { get; init; }
|
||||||
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
||||||
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
|
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
|
||||||
@@ -122,6 +151,8 @@ public sealed class InvitationBody
|
|||||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||||
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
[JsonPropertyName("payload")] public required string Payload { get; init; }
|
||||||
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
|
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
|
||||||
|
/// <summary>调用者能不能撤销这条邀请(后端按权限判断)</summary>
|
||||||
|
[JsonPropertyName("can_revoke")] public bool CanRevoke { get; init; }
|
||||||
}
|
}
|
||||||
public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } }
|
public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } }
|
||||||
public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } }
|
public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } }
|
||||||
@@ -138,6 +169,7 @@ public sealed class AccountDeviceBody
|
|||||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||||
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
|
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
|
||||||
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
|
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("current")] public required bool Current { get; init; }
|
[JsonPropertyName("current")] public required bool Current { get; init; }
|
||||||
}
|
}
|
||||||
public sealed class AccountChannelBody
|
public sealed class AccountChannelBody
|
||||||
@@ -162,6 +194,9 @@ public sealed class MemberBody
|
|||||||
|
|
||||||
/// <summary>该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。</summary>
|
/// <summary>该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。</summary>
|
||||||
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
|
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
|
||||||
|
|
||||||
|
/// <summary>调用者能不能移除这位成员(后端按权限判断)。</summary>
|
||||||
|
[JsonPropertyName("can_remove")] public bool CanRemove { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AccountProfileRequest
|
public sealed class AccountProfileRequest
|
||||||
@@ -178,7 +213,10 @@ public sealed class WorkspaceChannelRequest
|
|||||||
{
|
{
|
||||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||||
[JsonPropertyName("groupId")] public string? GroupId { get; init; }
|
[JsonPropertyName("groupId")] public string? GroupId { get; init; }
|
||||||
|
/// <summary>文件服务里为这个频道创建的文件域 id(不透明字符串,主后端只存不解释)</summary>
|
||||||
|
[JsonPropertyName("fileDomain")] public string? FileDomain { get; init; }
|
||||||
}
|
}
|
||||||
|
public sealed class WorkspaceFileDomainRequest { [JsonPropertyName("fileDomain")] public required string FileDomain { get; init; } }
|
||||||
public sealed class WorkspaceRoleRequest
|
public sealed class WorkspaceRoleRequest
|
||||||
{
|
{
|
||||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||||
@@ -242,6 +280,7 @@ public sealed class WorkspaceGroupCreatedBody
|
|||||||
[JsonSerializable(typeof(WorkspaceGroupCreatedBody))]
|
[JsonSerializable(typeof(WorkspaceGroupCreatedBody))]
|
||||||
[JsonSerializable(typeof(WorkspaceNameRequest))]
|
[JsonSerializable(typeof(WorkspaceNameRequest))]
|
||||||
[JsonSerializable(typeof(WorkspaceChannelRequest))]
|
[JsonSerializable(typeof(WorkspaceChannelRequest))]
|
||||||
|
[JsonSerializable(typeof(WorkspaceFileDomainRequest))]
|
||||||
[JsonSerializable(typeof(WorkspaceGroupRequest))]
|
[JsonSerializable(typeof(WorkspaceGroupRequest))]
|
||||||
[JsonSerializable(typeof(WorkspaceRoleRequest))]
|
[JsonSerializable(typeof(WorkspaceRoleRequest))]
|
||||||
[JsonSerializable(typeof(WorkspaceRoleMembersRequest))]
|
[JsonSerializable(typeof(WorkspaceRoleMembersRequest))]
|
||||||
@@ -250,6 +289,8 @@ public sealed class WorkspaceGroupCreatedBody
|
|||||||
[JsonSerializable(typeof(WorkspaceMoveRequest))]
|
[JsonSerializable(typeof(WorkspaceMoveRequest))]
|
||||||
[JsonSerializable(typeof(GoneLogBody))]
|
[JsonSerializable(typeof(GoneLogBody))]
|
||||||
[JsonSerializable(typeof(ServerInfoBody))]
|
[JsonSerializable(typeof(ServerInfoBody))]
|
||||||
|
[JsonSerializable(typeof(FileGrantRequest))]
|
||||||
|
[JsonSerializable(typeof(FileGrantBody))]
|
||||||
[JsonSerializable(typeof(ChannelInfo))]
|
[JsonSerializable(typeof(ChannelInfo))]
|
||||||
[JsonSerializable(typeof(List<ChannelInfo>))]
|
[JsonSerializable(typeof(List<ChannelInfo>))]
|
||||||
[JsonSerializable(typeof(ShareInfo))]
|
[JsonSerializable(typeof(ShareInfo))]
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -13,6 +13,7 @@
|
|||||||
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
|
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
|
||||||
<ProjectReference Include="..\WebDrop.Frontend\WebDrop.Frontend.csproj" />
|
<ProjectReference Include="..\WebDrop.Frontend\WebDrop.Frontend.csproj" />
|
||||||
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
|
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
|
||||||
|
<ProjectReference Include="..\WebDrop.Sync\WebDrop.Sync.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
+3
-1
@@ -120,7 +120,9 @@ public interface IResourceTx
|
|||||||
void DeleteChannelRows(Id16 id);
|
void DeleteChannelRows(Id16 id);
|
||||||
/// <summary>空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON</summary>
|
/// <summary>空间头像(data URL)单独存放,不放进每次请求都要读的设置 JSON</summary>
|
||||||
/// <summary>thumb 为 true 取缩略图(没有就用原图),否则取原图</summary>
|
/// <summary>thumb 为 true 取缩略图(没有就用原图),否则取原图</summary>
|
||||||
string? GetChannelAvatar(Id16 id, bool thumb);
|
AvatarBlob? GetChannelAvatar(Id16 id, bool thumb);
|
||||||
|
/// <summary>头像的版本标记(头像文件名,内容变了才会变);没有头像为 null</summary>
|
||||||
|
string? GetChannelAvatarVersion(Id16 id);
|
||||||
void SetChannelAvatar(Id16 id, string? avatar, string? thumb);
|
void SetChannelAvatar(Id16 id, string? avatar, string? thumb);
|
||||||
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
|
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
|
||||||
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@ public sealed class AccountProfile
|
|||||||
{
|
{
|
||||||
[JsonPropertyName("username")] public required string Username { get; init; }
|
[JsonPropertyName("username")] public required string Username { get; init; }
|
||||||
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
|
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
|
||||||
/// <summary>头像(data URL)。资料列表、空间成员等接口不再内联头像(动图可能有几 MB),只告诉有没有,头像另用 /accounts/users/{用户名}/avatar 取</summary>
|
/// <summary>头像文件名(服务端存的是磁盘文件)。资料列表、空间成员等接口不再内联头像(动图可能有几 MB),只告诉有没有,头像另用 /accounts/users/{用户名}/avatar 取</summary>
|
||||||
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
||||||
[JsonPropertyName("hasAvatar")] public bool HasAvatar { get; init; }
|
[JsonPropertyName("hasAvatar")] public bool HasAvatar { get; init; }
|
||||||
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
|
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
|
||||||
@@ -30,3 +30,6 @@ public static class AvatarData
|
|||||||
if (gif && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
|
if (gif && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>落在磁盘上的头像文件:下载时直接按路径发送,不读进内存。Hash 是内容的 SHA-256,也用作 ETag</summary>
|
||||||
|
public sealed record AvatarBlob(string Path, string Mime, string Hash);
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
namespace WebDrop.Core;
|
|
||||||
|
|
||||||
/// <summary>谁能做什么。规则只写在这里,服务和测试都调用它。</summary>
|
|
||||||
public static class ChannelPolicy
|
|
||||||
{
|
|
||||||
public static bool IsOwner(Principal p) => p.Role == Role.Owner;
|
|
||||||
|
|
||||||
public static bool CanRead(Principal p) => p.Role >= Role.Read;
|
|
||||||
|
|
||||||
/// <summary>成员写入需要频道允许上传,且服务端持有写令牌哈希。</summary>
|
|
||||||
public static bool CanWrite(Channel channel, Principal p) =>
|
|
||||||
p.Role == Role.Owner
|
|
||||||
|| (p.Role == Role.Write && channel.Settings.MembersCanUpload && channel.WriteHash is not null);
|
|
||||||
|
|
||||||
public static bool CanDeleteItem(Channel channel, Item item, Principal p)
|
|
||||||
{
|
|
||||||
if (p.Role == Role.Owner) return true;
|
|
||||||
if (p.Role < Role.Read) return false;
|
|
||||||
return channel.Settings.DeletePolicy switch
|
|
||||||
{
|
|
||||||
DeletePolicy.Anyone => true,
|
|
||||||
DeletePolicy.Own => item.AuthorAccount == p.AccountName,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+24
-10
@@ -30,15 +30,33 @@ public sealed partial class ChannelService
|
|||||||
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
|
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
{
|
{
|
||||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
if (principal.Role != Role.Owner) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
|
if (!ChannelPolicy.IsOwner(principal)) RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
|
||||||
return tx.ListInvitations(id);
|
return tx.ListInvitations(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>成员列表,附带调用者能不能移除每一位(后端判断,前端只显示按钮)</summary>
|
||||||
|
public IReadOnlyList<MemberView> MembersView(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
|
{
|
||||||
|
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
|
if (!ChannelPolicy.IsOwner(principal)) _ = Authenticate(tx, id, creds, Membership.Required);
|
||||||
|
var state = tx.GetWorkspace(id);
|
||||||
|
return tx.ListChannelMembers(id).Select(m => new MemberView(m, ChannelPolicy.CanRemoveMember(state, principal, m.Username, m.Role == "owner"))).ToList();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>邀请列表,附带调用者能不能撤销每一条</summary>
|
||||||
|
public IReadOnlyList<InvitationView> InvitationsView(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
|
{
|
||||||
|
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
|
if (!ChannelPolicy.IsOwner(principal)) principal = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
|
||||||
|
var state = tx.GetWorkspace(id);
|
||||||
|
return tx.ListInvitations(id).Select(i => new InvitationView(i, ChannelPolicy.CanRemoveMember(state, principal, i.Username, false))).ToList();
|
||||||
|
});
|
||||||
|
|
||||||
/// <summary>非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限</summary>
|
/// <summary>非空间主的调用方:必须是已加入的成员,且所在身份组被授予该权限</summary>
|
||||||
private Principal RequireMemberPermission(IResourceTx tx, Id16 id, Credentials creds, string permission)
|
private Principal RequireMemberPermission(IResourceTx tx, Id16 id, Credentials creds, string permission)
|
||||||
{
|
{
|
||||||
var (_, principal) = Authenticate(tx, id, creds, Membership.Required);
|
var (_, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||||
if (!HasWorkspacePermission(tx.GetWorkspace(id), principal, permission)) throw new WebDropException(ErrorCode.Forbidden);
|
if (!ChannelPolicy.HasPermission(tx.GetWorkspace(id), principal, permission)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
return principal;
|
return principal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +67,9 @@ public sealed partial class ChannelService
|
|||||||
store.Write(tx =>
|
store.Write(tx =>
|
||||||
{
|
{
|
||||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
|
bool targetIsOwner = tx.GetChannelMember(id, username)?.Role == "owner";
|
||||||
if (principal.Role != Role.Owner)
|
if (!ChannelPolicy.IsOwner(principal)) principal = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
|
||||||
{
|
if (!ChannelPolicy.CanRemoveMember(tx.GetWorkspace(id), principal, username, targetIsOwner)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
// 被授权的成员不能移除自己,也不能移除同样有移除权限的人
|
|
||||||
var acting = RequireMemberPermission(tx, id, creds, WorkspacePermission.RemoveMembers);
|
|
||||||
if (username == acting.AccountName || HasWorkspacePermission(tx.GetWorkspace(id), new Principal(Role.Write, username), WorkspacePermission.RemoveMembers)) throw new WebDropException(ErrorCode.Forbidden);
|
|
||||||
}
|
|
||||||
tx.DeleteInvitation(id, username);
|
tx.DeleteInvitation(id, username);
|
||||||
tx.DeleteKeyPackage(id, username);
|
tx.DeleteKeyPackage(id, username);
|
||||||
tx.DeleteChannelMember(id, username);
|
tx.DeleteChannelMember(id, username);
|
||||||
@@ -76,7 +90,7 @@ public sealed partial class ChannelService
|
|||||||
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
|
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
{
|
{
|
||||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, Membership.Required);
|
if (!ChannelPolicy.IsOwner(principal)) _ = Authenticate(tx, id, creds, Membership.Required);
|
||||||
return tx.ListChannelMembers(id);
|
return tx.ListChannelMembers(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
namespace WebDrop.Core;
|
||||||
|
|
||||||
|
/// <summary>谁能做什么。规则只写在这里,服务和测试都调用它。</summary>
|
||||||
|
public static class ChannelPolicy
|
||||||
|
{
|
||||||
|
public static bool IsOwner(Principal p) => p.Role == Role.Owner;
|
||||||
|
|
||||||
|
public static bool CanRead(Principal p) => p.Role >= Role.Read;
|
||||||
|
|
||||||
|
/// <summary>成员写入需要频道允许上传,且服务端持有写令牌哈希。</summary>
|
||||||
|
public static bool CanWrite(Channel channel, Principal p) =>
|
||||||
|
p.Role == Role.Owner
|
||||||
|
|| (p.Role == Role.Write && channel.Settings.MembersCanUpload && channel.WriteHash is not null);
|
||||||
|
|
||||||
|
// ───── 空间所有者与频道访问授权(ChannelAccess):谁能读、写哪个频道的哪个数据域。其他地方只调用这里,不再各写一遍
|
||||||
|
|
||||||
|
/// <summary>空间的所有者:持有所有者令牌的人,或者空间的创建账号</summary>
|
||||||
|
public static bool IsSpaceOwner(Channel space, Principal p) => p.Role == Role.Owner || p.AccountName == space.CreatorAccount;
|
||||||
|
|
||||||
|
/// <summary>某个账号在某个频道上的访问授权;没有记录返回 null</summary>
|
||||||
|
public static ChannelAccess? AccessOf(CryptoState crypto, string? account, string channelId) =>
|
||||||
|
crypto.Access.Find(a => a.ChannelId == channelId && a.Username == account);
|
||||||
|
|
||||||
|
/// <summary>这条授权允许读这个数据域</summary>
|
||||||
|
public static bool CanReadDomain(ChannelAccess? access, CryptoDomain domain) =>
|
||||||
|
access is not null && (domain == CryptoDomain.Chat ? access.ChatRead : access.FilesRead);
|
||||||
|
|
||||||
|
/// <summary>这条授权允许写这个数据域(聊天域写消息,文件域上传)</summary>
|
||||||
|
public static bool CanWriteDomain(ChannelAccess? access, CryptoDomain domain) =>
|
||||||
|
access is not null && (domain == CryptoDomain.Chat ? access.ChatWrite : access.FilesUpload);
|
||||||
|
|
||||||
|
/// <summary>读一个对象:能读它所在的数据域,并且对象不早于授权生效的版本(关闭历史可读时,新成员看不到加入前的内容)</summary>
|
||||||
|
public static bool CanReadObject(ChannelAccess? access, CryptoDomain domain, long objectRevision) =>
|
||||||
|
CanReadDomain(access, domain) && objectRevision >= (domain == CryptoDomain.Chat ? access!.ChatFromRevision : access!.FilesFromRevision);
|
||||||
|
|
||||||
|
/// <summary>频道对这个账号可见:聊天或文件任一域能读</summary>
|
||||||
|
public static bool CanSeeChannel(ChannelAccess access) => access.ChatRead || access.FilesRead;
|
||||||
|
|
||||||
|
/// <summary>能碰这个频道的文件域:能读或能上传</summary>
|
||||||
|
public static bool CanUseFiles(ChannelAccess access) => access.FilesRead || access.FilesUpload;
|
||||||
|
|
||||||
|
/// <summary>能往频道里放东西:聊天或文件任一域能写</summary>
|
||||||
|
public static bool CanWriteAnyDomain(ChannelAccess? access) => access is not null && (access.ChatWrite || access.FilesUpload);
|
||||||
|
|
||||||
|
/// <summary>这个频道里能读某个数据域的账号</summary>
|
||||||
|
public static IEnumerable<ChannelAccess> Readers(CryptoState crypto, string channelId, CryptoDomain domain) =>
|
||||||
|
crypto.Access.Where(a => a.ChannelId == channelId && CanReadDomain(a, domain));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 这个空间是否已经按授权记录来约束成员:加密空间有了密钥信封,明文空间有了授权记录或对象登记之后才约束。
|
||||||
|
/// 之前(刚建好、还没初始化)所有成员的行为和旧版一致。
|
||||||
|
/// </summary>
|
||||||
|
public static bool GrantsEnforced(Channel space, CryptoState crypto) =>
|
||||||
|
space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0;
|
||||||
|
|
||||||
|
// ───── 空间权限(身份组授予)与结构管理
|
||||||
|
|
||||||
|
/// <summary>有没有这项空间权限:所有者拥有全部,其余看身份组授予</summary>
|
||||||
|
public static bool HasPermission(WorkspaceState? state, Principal principal, string permission) =>
|
||||||
|
principal.Role == Role.Owner
|
||||||
|
|| principal.AccountName is { } account && state?.Roles.Any(r => r.Members.Contains(account) && r.Permissions.Contains(permission)) == true;
|
||||||
|
|
||||||
|
/// <summary>重命名频道、调整频道所属分组、管理分组(所有者,或被授予「管理频道和分组」)</summary>
|
||||||
|
public static bool CanManageStructure(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.ManageStructure);
|
||||||
|
|
||||||
|
/// <summary>移除成员、撤销邀请的权限(所有者,或被授予「移除成员」)。具体能移除谁见 <see cref="CanRemoveMember"/></summary>
|
||||||
|
public static bool CanRemoveMembers(WorkspaceState? state, Principal p) => HasPermission(state, p, WorkspacePermission.RemoveMembers);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 谁能移除谁:所有者能移除除自己外的任何人;被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人。
|
||||||
|
/// 执行(RevokeInvitation)和给前端的可移除标记(MembersView)都用这一处。
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanRemoveMember(WorkspaceState? state, Principal acting, string target, bool targetIsOwner)
|
||||||
|
{
|
||||||
|
if (targetIsOwner) return false;
|
||||||
|
if (acting.Role == Role.Owner) return true;
|
||||||
|
return acting.AccountName is { } actor && actor != target
|
||||||
|
&& CanRemoveMembers(state, acting)
|
||||||
|
&& !CanRemoveMembers(state, new Principal(Role.Write, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下面这几项目前都只有所有者:新建频道(涉及密钥)、文件夹、邀请、改空间设置。要放开时只改这里
|
||||||
|
|
||||||
|
public static bool CanCreateChannel(Principal p) => IsOwner(p);
|
||||||
|
public static bool CanManageFolders(Principal p) => IsOwner(p);
|
||||||
|
public static bool CanInvite(Principal p) => IsOwner(p);
|
||||||
|
public static bool CanEditSettings(Principal p) => IsOwner(p);
|
||||||
|
public static bool CanManageRoles(Principal p) => IsOwner(p);
|
||||||
|
|
||||||
|
// ───── 删除条目
|
||||||
|
|
||||||
|
/// <summary>能删任何人发的条目:所有者,或空间的删除策略是 anyone</summary>
|
||||||
|
public static bool CanDeleteAnyItem(Channel channel, Principal p) =>
|
||||||
|
IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy == DeletePolicy.Anyone;
|
||||||
|
|
||||||
|
/// <summary>能删自己发的条目:所有者,或删除策略是 own / anyone</summary>
|
||||||
|
public static bool CanDeleteOwnItem(Channel channel, Principal p) =>
|
||||||
|
IsOwner(p) || p.Role >= Role.Read && channel.Settings.DeletePolicy is DeletePolicy.Own or DeletePolicy.Anyone;
|
||||||
|
|
||||||
|
/// <summary>某个具体条目能不能删:能删任何人的,或者是自己的并且能删自己的</summary>
|
||||||
|
public static bool CanDeleteItem(Channel channel, Item item, Principal p) =>
|
||||||
|
CanDeleteAnyItem(channel, p) || item.AuthorAccount == p.AccountName && CanDeleteOwnItem(channel, p);
|
||||||
|
}
|
||||||
+215
-46
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using WebDrop.FileProtocol;
|
||||||
|
|
||||||
namespace WebDrop.Core;
|
namespace WebDrop.Core;
|
||||||
|
|
||||||
@@ -11,8 +12,18 @@ public sealed partial class ChannelService(
|
|||||||
IBlobStore blobs,
|
IBlobStore blobs,
|
||||||
IClock clock,
|
IClock clock,
|
||||||
ServerLimits limits,
|
ServerLimits limits,
|
||||||
IChannelEvents events)
|
IChannelEvents events,
|
||||||
|
IFileGrants? fileGrants = null)
|
||||||
{
|
{
|
||||||
|
private IFileGrants Files => fileGrants ?? NoFileGrants.Instance;
|
||||||
|
/// <summary>帧内容在文件域里的帧,BlobKey 以它开头;主后端只有头部、长度和摘要</summary>
|
||||||
|
public const string FilesPrefix = "files:";
|
||||||
|
/// <summary>这个条目是文件域的对象(不含目录控制帧)。所在频道没有分配文件域(文件服务没开、小文件)时,文件和聊天一样存在主后端</summary>
|
||||||
|
private static bool IsFileObject(IResourceTx tx, Id16 channelId, Envelope env) =>
|
||||||
|
!env.IsControl && FileDomainOf(tx, channelId, env.ItemId) is not null;
|
||||||
|
|
||||||
|
private static bool IsRemote(string? blobKey) => blobKey is not null && blobKey.StartsWith(FilesPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
/// <summary>Required:必须已是频道成员;None:只验证身份(加入、查看信息等)。</summary>
|
/// <summary>Required:必须已是频道成员;None:只验证身份(加入、查看信息等)。</summary>
|
||||||
private enum Membership { None, Required }
|
private enum Membership { None, Required }
|
||||||
|
|
||||||
@@ -68,14 +79,14 @@ public sealed partial class ChannelService(
|
|||||||
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
|
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
{
|
{
|
||||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||||
return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatar(id, thumb: true));
|
return ToInfo(channel, tx.CountMembers(id), principal, tx.GetChannelAvatarVersion(id), tx.GetWorkspace(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>空间头像原图(预览时才下载)。需要能读这个空间</summary>
|
/// <summary>空间头像(thumb 为缩略图,否则原图)。需要能读这个空间</summary>
|
||||||
public string? GetChannelAvatarFull(Id16 id, Credentials creds) => store.Read(tx =>
|
public AvatarBlob? GetChannelAvatarBlob(Id16 id, Credentials creds, bool thumb) => store.Read(tx =>
|
||||||
{
|
{
|
||||||
_ = Authenticate(tx, id, creds, Membership.None);
|
_ = Authenticate(tx, id, creds, Membership.None);
|
||||||
return tx.GetChannelAvatar(id, thumb: false);
|
return tx.GetChannelAvatar(id, thumb);
|
||||||
});
|
});
|
||||||
|
|
||||||
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
|
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
|
||||||
@@ -101,7 +112,7 @@ public sealed partial class ChannelService(
|
|||||||
tx.SetJoinPassword(id, hash, salt);
|
tx.SetJoinPassword(id, hash, salt);
|
||||||
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
|
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
|
||||||
}
|
}
|
||||||
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatar: tx.GetChannelAvatar(id, thumb: true));
|
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id), avatarVersion: tx.GetChannelAvatarVersion(id));
|
||||||
});
|
});
|
||||||
events.SettingsChanged(id);
|
events.SettingsChanged(id);
|
||||||
return info;
|
return info;
|
||||||
@@ -160,7 +171,9 @@ public sealed partial class ChannelService(
|
|||||||
{
|
{
|
||||||
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
||||||
CheckWritable(channel, principal, env);
|
CheckWritable(channel, principal, env);
|
||||||
CheckObjectWrite(tx, channel, principal, env);
|
CheckObjectWrite(tx, channel, principal, env.ItemId);
|
||||||
|
// 文件域的对象不存主后端:字节在文件域里,主后端只在整个文件传完后一次登记头部和摘要(见 RegisterFileItem)
|
||||||
|
if (IsFileObject(tx, channelId, env)) throw new WebDropException(ErrorCode.UseFileApi);
|
||||||
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
|
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
|
||||||
});
|
});
|
||||||
if (existing is not null) return Resolve(existing, digest);
|
if (existing is not null) return Resolve(existing, digest);
|
||||||
@@ -192,6 +205,166 @@ public sealed partial class ChannelService(
|
|||||||
return outcome.Result;
|
return outcome.Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>登记整个文件时,每一帧的负载长度和摘要(摘要是整帧封装的 SHA-256)</summary>
|
||||||
|
public readonly record struct FrameEntry(int PayloadLength, byte[] Digest);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一次登记整个文件:字节都在文件域里,逐帧登记要为每个 1 MiB 往返一次主后端,大文件的上传速度被它拖死。
|
||||||
|
/// 请求带的是末帧的头部和每一帧的(长度,摘要)。规则和逐帧登记完全一样:第 0 帧创建条目(含下载次数、日志里的公布),
|
||||||
|
/// 中间帧只落库,末帧验签名事件(清单摘要要和这里登记的所有摘要对得上)。整个登记在一个事务里,要么全成要么全不成。
|
||||||
|
/// 重复登记同一个文件(摘要全一致)幂等。
|
||||||
|
/// </summary>
|
||||||
|
public AppendResult RegisterFileItem(
|
||||||
|
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> finalHeader, IReadOnlyList<FrameEntry> frames,
|
||||||
|
long? maxDownloads, SignedContentEventProof? signedEvent = null)
|
||||||
|
{
|
||||||
|
if (!Envelope.TryParseHeader(finalHeader.Span, out var last) || !last.IsFinal || frames.Count < 1
|
||||||
|
|| last.FrameIndex != frames.Count - 1 || frames[^1].PayloadLength != last.PayloadLength) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
if (frames.Count > limits.MaxFramesPerItem) throw new WebDropException(ErrorCode.TooLarge);
|
||||||
|
if (frames.Any(f => f.Digest.Length != 32 || f.PayloadLength < 0 || f.PayloadLength > limits.MaxPayloadBytes)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
// 每一帧的头部由末帧头部推出:条目级标志一致,只有末帧带 FINAL
|
||||||
|
FrameFlags itemFlags = last.Flags & ~FrameFlags.Final;
|
||||||
|
Envelope EnvelopeOf(int index) => new(index == frames.Count - 1 ? last.Flags : itemFlags, last.ItemId, (uint)index, frames[index].PayloadLength);
|
||||||
|
ValidateShape(EnvelopeOf(0)); ValidateShape(last);
|
||||||
|
|
||||||
|
var outcomes = store.Write<(AppendOutcome? First, AppendOutcome? Last, long Seq)>(tx =>
|
||||||
|
{
|
||||||
|
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
||||||
|
for (int i = 0; i < frames.Count; i++) CheckWritable(channel, principal, EnvelopeOf(i));
|
||||||
|
CheckObjectWrite(tx, channel, principal, last.ItemId);
|
||||||
|
if (!IsFileObject(tx, channelId, last)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
|
||||||
|
// 已经登记过同一个文件:摘要全部一致就是重复请求,否则冲突
|
||||||
|
if (tx.GetItem(channelId, last.ItemId) is not null)
|
||||||
|
{
|
||||||
|
var stored = tx.ListFrames(channelId, last.ItemId, 0, limits.MaxFramesPerItem);
|
||||||
|
if (stored.Count != frames.Count || stored.Select((f, i) => f.Index == i && f.Digest.AsSpan().SequenceEqual(frames[i].Digest)).Any(same => !same))
|
||||||
|
throw new WebDropException(ErrorCode.Conflict);
|
||||||
|
return (First: (AppendOutcome?)null, Last: (AppendOutcome?)null, Seq: stored[^1].Seq);
|
||||||
|
}
|
||||||
|
long middleBytes = 0;
|
||||||
|
for (int i = 1; i < frames.Count - 1; i++) middleBytes += EnvelopeOf(i).TotalLength;
|
||||||
|
if (channel.Bytes + middleBytes > limits.MaxWorkspaceBytes) throw new WebDropException(ErrorCode.TooLarge);
|
||||||
|
|
||||||
|
string BlobKeyOf(int i) => $"{FilesPrefix}{channelId.ToHex()}/{last.ItemId.ToHex()}/{i}";
|
||||||
|
var first = AppendInTx(tx, channelId, creds, EnvelopeOf(0), ReadOnlyMemory<byte>.Empty, frames[0].Digest, BlobKeyOf(0), maxDownloads,
|
||||||
|
frames.Count == 1 ? signedEvent : null);
|
||||||
|
if (frames.Count == 1) return (First: (AppendOutcome?)first, Last: null, Seq: first.LastSeq);
|
||||||
|
|
||||||
|
// 中间帧(受限条目的中间帧不进日志):只落库,条目计数一次性补上
|
||||||
|
for (int i = 1; i < frames.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var env = EnvelopeOf(i);
|
||||||
|
tx.InsertFrame(new StoredFrame(channelId, last.ItemId, (uint)i, tx.NextSeq(channelId), env.TotalLength, frames[i].Digest, null, BlobKeyOf(i)));
|
||||||
|
}
|
||||||
|
if (frames.Count > 2)
|
||||||
|
{
|
||||||
|
var item = tx.GetItem(channelId, last.ItemId)!;
|
||||||
|
tx.UpdateItem(item with { FrameCount = item.FrameCount + frames.Count - 2, MaxIndex = Math.Max(item.MaxIndex, frames.Count - 2), Bytes = item.Bytes + middleBytes });
|
||||||
|
tx.AddChannelBytes(channelId, middleBytes);
|
||||||
|
}
|
||||||
|
var final = AppendInTx(tx, channelId, creds, last, ReadOnlyMemory<byte>.Empty, frames[^1].Digest, BlobKeyOf(frames.Count - 1), null, signedEvent);
|
||||||
|
return (First: (AppendOutcome?)first, Last: final, Seq: final.Result.Seq);
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var outcome in new[] { outcomes.First, outcomes.Last })
|
||||||
|
{
|
||||||
|
if (outcome is null) continue;
|
||||||
|
foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix);
|
||||||
|
events.Advanced(channelId, outcome.LastSeq);
|
||||||
|
if (outcome.NotifySettings) events.SettingsChanged(channelId);
|
||||||
|
}
|
||||||
|
return new AppendResult { Seq = outcomes.Seq, Created = outcomes.First is not null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>条目所属频道的文件域(文件域的对象才有)</summary>
|
||||||
|
private static string? FileDomainOf(IResourceTx tx, Id16 spaceId, Id16 itemId)
|
||||||
|
{
|
||||||
|
var obj = tx.GetWorkspaceCrypto(spaceId).Objects.Find(o => o.ItemId == itemId.ToBase64Url() && o.Domain == CryptoDomain.Files);
|
||||||
|
return obj is null ? null : tx.GetWorkspace(spaceId)?.Channels.Find(c => c.Id == obj.ChannelId)?.FileDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>创建文件域的票据:只有空间所有者和有「管理频道和分组」权限的人能要。票据绑定空间 id</summary>
|
||||||
|
public FileGrant IssueCreateGrant(Id16 spaceId, Credentials creds)
|
||||||
|
{
|
||||||
|
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
store.Read(tx =>
|
||||||
|
{
|
||||||
|
var (space, principal) = Authenticate(tx, spaceId, creds, Membership.Required);
|
||||||
|
if (!ChannelPolicy.CanManageStructure(tx.GetWorkspace(spaceId), principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return Files.Issue(FileOp.Create, spaceId.ToHex(), spaceId.ToHex(), "*", 0, clock.NowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>上传票据:确认能写这个条目所在的频道,然后给客户端文件域的访问凭证。</summary>
|
||||||
|
public FileGrant IssueUploadGrant(Id16 id, Credentials creds, Id16 itemId)
|
||||||
|
{
|
||||||
|
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
string domain = store.Read(tx =>
|
||||||
|
{
|
||||||
|
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||||
|
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|
CheckObjectWrite(tx, space, principal, itemId);
|
||||||
|
return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.Conflict);
|
||||||
|
});
|
||||||
|
return Files.Issue(FileOp.Put, id.ToHex(), domain, itemId.ToHex(), 0, clock.NowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件域的查询票据(看容量和用量):能读这个频道的文件就行。票据的条目是 "*",只能查域,读不了任何文件。
|
||||||
|
/// </summary>
|
||||||
|
public FileGrant IssueDomainGrant(Id16 id, Credentials creds, string threadId)
|
||||||
|
{
|
||||||
|
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
string domain = store.Read(tx =>
|
||||||
|
{
|
||||||
|
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||||
|
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
var thread = workspace.Channels.Find(c => c.Id == threadId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
var crypto = tx.GetWorkspaceCrypto(id);
|
||||||
|
bool allowed = ChannelPolicy.IsSpaceOwner(space, principal)
|
||||||
|
|| crypto.Access.Count == 0 && space.Mode == ChannelMode.Raw
|
||||||
|
|| ChannelPolicy.AccessOf(crypto, principal.AccountName, threadId) is { } access && ChannelPolicy.CanUseFiles(access);
|
||||||
|
if (!allowed) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|
return thread.FileDomain ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
});
|
||||||
|
return Files.Issue(FileOp.Get, id.ToHex(), domain, "*", 0, clock.NowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>删除票据:确认调用者按原来的规则能删这个条目,再签。客户端先拿票据,再删主后端的条目,最后用票据清文件域里的字节</summary>
|
||||||
|
public FileGrant IssueDeleteGrant(Id16 id, Credentials creds, Id16 itemId)
|
||||||
|
{
|
||||||
|
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
string domain = store.Read(tx =>
|
||||||
|
{
|
||||||
|
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||||
|
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
if (!ChannelPolicy.CanDeleteItem(space, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|
return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
});
|
||||||
|
return Files.Issue(FileOp.Delete, id.ToHex(), domain, itemId.ToHex(), 0, clock.NowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下载票据:按原来的规则鉴权并计次(只有 from=0 才计),再签票据。条目没传完时返回冲突,让客户端稍后再来。
|
||||||
|
/// </summary>
|
||||||
|
public FileGrant IssueDownloadGrant(Id16 id, Credentials creds, Id16 itemId, uint fromIndex)
|
||||||
|
{
|
||||||
|
if (!Files.Enabled) throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
string domain = store.Read(tx =>
|
||||||
|
{
|
||||||
|
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||||
|
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|
if (item.FinalIndex is null) throw new WebDropException(ErrorCode.Conflict);
|
||||||
|
return FileDomainOf(tx, id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
});
|
||||||
|
StartItemDownload(id, creds, itemId, fromIndex, allowRemote: true);
|
||||||
|
return Files.Issue(FileOp.Get, id.ToHex(), domain, itemId.ToHex(), fromIndex, clock.NowMs);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes, bool notifySettings = false)
|
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes, bool notifySettings = false)
|
||||||
{
|
{
|
||||||
public AppendResult Result { get; } = result;
|
public AppendResult Result { get; } = result;
|
||||||
@@ -206,7 +379,8 @@ public sealed partial class ChannelService(
|
|||||||
{
|
{
|
||||||
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
||||||
CheckWritable(channel, principal, env);
|
CheckWritable(channel, principal, env);
|
||||||
CheckObjectWrite(tx, channel, principal, env);
|
CheckObjectWrite(tx, channel, principal, env.ItemId);
|
||||||
|
int bodyLength = IsRemote(blobKey) ? env.TotalLength : body.Length;
|
||||||
|
|
||||||
// 并发下预检之后可能已有同一帧
|
// 并发下预检之后可能已有同一帧
|
||||||
if (tx.GetFrame(channelId, env.ItemId, env.FrameIndex) is { } dup)
|
if (tx.GetFrame(channelId, env.ItemId, env.FrameIndex) is { } dup)
|
||||||
@@ -220,7 +394,6 @@ public sealed partial class ChannelService(
|
|||||||
var workspaceState = objectEnvelope is null ? null : tx.GetWorkspace(channelId);
|
var workspaceState = objectEnvelope is null ? null : tx.GetWorkspace(channelId);
|
||||||
var workspaceChannel = objectEnvelope is null ? null : workspaceState?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
|
var workspaceChannel = objectEnvelope is null ? null : workspaceState?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
|
||||||
if (objectEnvelope is not null && workspaceChannel is null) throw new WebDropException(ErrorCode.BadRequest);
|
if (objectEnvelope is not null && workspaceChannel is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
bool countsTowardFileQuota = objectEnvelope?.Domain == CryptoDomain.Files && !env.IsControl;
|
|
||||||
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
|
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
if (objectEnvelope is not null && env.IsFinal)
|
if (objectEnvelope is not null && env.IsFinal)
|
||||||
{
|
{
|
||||||
@@ -285,14 +458,12 @@ public sealed partial class ChannelService(
|
|||||||
if (item.MaxIndex > env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
if (item.MaxIndex > env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
if (item.FinalIndex is { } f && f != env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
if (item.FinalIndex is { } f && f != env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
}
|
}
|
||||||
if (objectEnvelope is null && channel.Bytes + body.Length > limits.MaxChannelBytes
|
if (objectEnvelope is null && channel.Bytes + bodyLength > limits.MaxChannelBytes
|
||||||
|| objectEnvelope is not null && channel.Bytes + body.Length > limits.MaxWorkspaceBytes)
|
|| objectEnvelope is not null && channel.Bytes + bodyLength > limits.MaxWorkspaceBytes)
|
||||||
throw new WebDropException(ErrorCode.TooLarge);
|
|
||||||
if (countsTowardFileQuota && workspaceChannel!.FileBytes + body.Length > limits.MaxWorkspaceChannelBytes)
|
|
||||||
throw new WebDropException(ErrorCode.TooLarge);
|
throw new WebDropException(ErrorCode.TooLarge);
|
||||||
|
|
||||||
long seq = tx.NextSeq(channelId);
|
long seq = tx.NextSeq(channelId);
|
||||||
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest,
|
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, bodyLength, digest,
|
||||||
blobKey is null ? body.ToArray() : null, blobKey));
|
blobKey is null ? body.ToArray() : null, blobKey));
|
||||||
|
|
||||||
if (signedEvent is not null && objectEnvelope is not null)
|
if (signedEvent is not null && objectEnvelope is not null)
|
||||||
@@ -302,7 +473,8 @@ public sealed partial class ChannelService(
|
|||||||
tx.PutWorkspaceCrypto(channelId, crypto);
|
tx.PutWorkspaceCrypto(channelId, crypto);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!env.IsGated)
|
// 受限帧和放在文件服务上的帧不进日志(日志里的帧自带内容),只在第 0 帧公布一次
|
||||||
|
if (!env.IsGated && !IsRemote(blobKey))
|
||||||
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Frame, env.ItemId, env.FrameIndex, 0, now));
|
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Frame, env.ItemId, env.FrameIndex, 0, now));
|
||||||
else if (env.FrameIndex == 0)
|
else if (env.FrameIndex == 0)
|
||||||
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Announce, env.ItemId, 0, 0, now));
|
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Announce, env.ItemId, 0, 0, now));
|
||||||
@@ -312,9 +484,9 @@ public sealed partial class ChannelService(
|
|||||||
FrameCount = item.FrameCount + 1,
|
FrameCount = item.FrameCount + 1,
|
||||||
MaxIndex = Math.Max(item.MaxIndex, env.FrameIndex),
|
MaxIndex = Math.Max(item.MaxIndex, env.FrameIndex),
|
||||||
FinalIndex = env.IsFinal ? env.FrameIndex : item.FinalIndex,
|
FinalIndex = env.IsFinal ? env.FrameIndex : item.FinalIndex,
|
||||||
Bytes = item.Bytes + body.Length,
|
Bytes = item.Bytes + bodyLength,
|
||||||
});
|
});
|
||||||
tx.AddChannelBytes(channelId, body.Length);
|
tx.AddChannelBytes(channelId, bodyLength);
|
||||||
tx.TouchChannel(channelId, Math.Max(channel.ExpiresAt, now + ttlMs));
|
tx.TouchChannel(channelId, Math.Max(channel.ExpiresAt, now + ttlMs));
|
||||||
|
|
||||||
long lastSeq = seq;
|
long lastSeq = seq;
|
||||||
@@ -344,16 +516,7 @@ public sealed partial class ChannelService(
|
|||||||
if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned);
|
if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countsTowardFileQuota)
|
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes);
|
||||||
{
|
|
||||||
var latestWorkspace = tx.GetWorkspace(channelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
|
||||||
var latestChannel = latestWorkspace.Channels.Find(c => c.Id == objectEnvelope!.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
|
||||||
latestChannel.FileBytes += body.Length;
|
|
||||||
tx.PutWorkspace(channelId, latestWorkspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes,
|
|
||||||
countsTowardFileQuota && env.IsFinal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppendResult Resolve(StoredFrame existing, byte[] digest) =>
|
private static AppendResult Resolve(StoredFrame existing, byte[] digest) =>
|
||||||
@@ -435,7 +598,7 @@ public sealed partial class ChannelService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>开始拉取条目:鉴权,并按规则计一次下载。<paramref name="fromIndex"/> 为 0 才计数,大于 0 视为续传。</summary>
|
/// <summary>开始拉取条目:鉴权,并按规则计一次下载。<paramref name="fromIndex"/> 为 0 才计数,大于 0 视为续传。</summary>
|
||||||
public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex)
|
public void StartItemDownload(Id16 id, Credentials creds, Id16 itemId, uint fromIndex, bool allowRemote = false)
|
||||||
{
|
{
|
||||||
store.Write(tx =>
|
store.Write(tx =>
|
||||||
{
|
{
|
||||||
@@ -443,6 +606,7 @@ public sealed partial class ChannelService(
|
|||||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|
if (!allowRemote && IsRemote(tx.GetFrame(id, itemId, 0)?.BlobKey)) throw new WebDropException(ErrorCode.UseFileApi);
|
||||||
|
|
||||||
long now = clock.NowMs;
|
long now = clock.NowMs;
|
||||||
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
|
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
|
||||||
@@ -504,8 +668,11 @@ public sealed partial class ChannelService(
|
|||||||
return new ItemChunk(result, next, complete);
|
return new ItemChunk(result, next, complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<byte[]?> LoadFrameBytesAsync(StoredFrame frame, CancellationToken ct) =>
|
private async ValueTask<byte[]?> LoadFrameBytesAsync(StoredFrame frame, CancellationToken ct)
|
||||||
frame.Inline ?? (frame.BlobKey is null ? null : await blobs.ReadAsync(frame.BlobKey, ct).ConfigureAwait(false));
|
{
|
||||||
|
if (IsRemote(frame.BlobKey)) throw new WebDropException(ErrorCode.UseFileApi);
|
||||||
|
return frame.Inline ?? (frame.BlobKey is null ? null : await blobs.ReadAsync(frame.BlobKey, ct).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────── 删除 ─────────────────────────
|
// ───────────────────────── 删除 ─────────────────────────
|
||||||
|
|
||||||
@@ -556,18 +723,6 @@ public sealed partial class ChannelService(
|
|||||||
|
|
||||||
private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List<string> prefixes)
|
private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List<string> prefixes)
|
||||||
{
|
{
|
||||||
var crypto = tx.GetWorkspaceCrypto(channelId);
|
|
||||||
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == item.ItemId.ToBase64Url());
|
|
||||||
if (objectEnvelope?.Domain == CryptoDomain.Files && (item.Flags & FrameFlags.Control) == 0)
|
|
||||||
{
|
|
||||||
var workspace = tx.GetWorkspace(channelId);
|
|
||||||
var workspaceChannel = workspace?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
|
|
||||||
if (workspace is not null && workspaceChannel is not null)
|
|
||||||
{
|
|
||||||
workspaceChannel.FileBytes = Math.Max(0, workspaceChannel.FileBytes - item.Bytes);
|
|
||||||
tx.PutWorkspace(channelId, workspace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx.DeleteItemRows(channelId, item.ItemId);
|
tx.DeleteItemRows(channelId, item.ItemId);
|
||||||
tx.AddChannelBytes(channelId, -item.Bytes);
|
tx.AddChannelBytes(channelId, -item.Bytes);
|
||||||
long seq = tx.NextSeq(channelId);
|
long seq = tx.NextSeq(channelId);
|
||||||
@@ -687,11 +842,24 @@ public sealed partial class ChannelService(
|
|||||||
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatar = null) => new()
|
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null, string? avatarVersion = null, WorkspaceState? workspace = null)
|
||||||
{
|
{
|
||||||
Avatar = avatar,
|
// 没有调用者(创建、改设置时)就是所有者本人
|
||||||
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)),
|
var who = principal ?? new Principal(Role.Owner, channel.CreatorAccount);
|
||||||
CanManageStructure = principal is null || principal.Role == Role.Owner,
|
return new()
|
||||||
|
{
|
||||||
|
AvatarVersion = avatarVersion,
|
||||||
|
IsOwner = ChannelPolicy.IsOwner(who),
|
||||||
|
CanUpload = ChannelPolicy.CanWrite(channel, who),
|
||||||
|
CanManageStructure = ChannelPolicy.CanManageStructure(workspace, who),
|
||||||
|
CanCreateChannel = ChannelPolicy.CanCreateChannel(who),
|
||||||
|
CanManageFolders = ChannelPolicy.CanManageFolders(who),
|
||||||
|
CanInvite = ChannelPolicy.CanInvite(who),
|
||||||
|
CanEditSettings = ChannelPolicy.CanEditSettings(who),
|
||||||
|
CanManageRoles = ChannelPolicy.CanManageRoles(who),
|
||||||
|
CanRemoveMembers = ChannelPolicy.CanRemoveMembers(workspace, who),
|
||||||
|
CanDeleteAny = ChannelPolicy.CanDeleteAnyItem(channel, who),
|
||||||
|
CanDeleteOwn = ChannelPolicy.CanDeleteOwnItem(channel, who),
|
||||||
Id = channel.Id.ToBase64Url(),
|
Id = channel.Id.ToBase64Url(),
|
||||||
CreatorAccount = channel.CreatorAccount,
|
CreatorAccount = channel.CreatorAccount,
|
||||||
PasswordRequired = channel.JoinPasswordHash is not null,
|
PasswordRequired = channel.JoinPasswordHash is not null,
|
||||||
@@ -703,4 +871,5 @@ public sealed partial class ChannelService(
|
|||||||
CreatedAt = channel.CreatedAt,
|
CreatedAt = channel.CreatedAt,
|
||||||
ExpiresAt = channel.ExpiresAt,
|
ExpiresAt = channel.ExpiresAt,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -52,12 +52,27 @@ public sealed class ShareItemInfo
|
|||||||
|
|
||||||
public sealed class ChannelInfo
|
public sealed class ChannelInfo
|
||||||
{
|
{
|
||||||
|
// ───── 调用者在这个空间里的能力。前端只读不算:规则只在后端一处
|
||||||
|
/// <summary>调用者是空间所有者</summary>
|
||||||
|
[JsonPropertyName("is_owner")] public bool IsOwner { get; init; }
|
||||||
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
|
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
|
||||||
|
/// <summary>能重命名频道、管理分组(所有者,或被授予「管理频道和分组」)</summary>
|
||||||
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
|
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
|
||||||
|
[JsonPropertyName("can_create_channel")] public bool CanCreateChannel { get; init; }
|
||||||
|
[JsonPropertyName("can_manage_folders")] public bool CanManageFolders { get; init; }
|
||||||
|
[JsonPropertyName("can_invite")] public bool CanInvite { get; init; }
|
||||||
|
[JsonPropertyName("can_edit_settings")] public bool CanEditSettings { get; init; }
|
||||||
|
[JsonPropertyName("can_manage_roles")] public bool CanManageRoles { get; init; }
|
||||||
|
/// <summary>能移除成员、撤销邀请;具体能移除谁见成员列表里每一项的 can_remove</summary>
|
||||||
|
[JsonPropertyName("can_remove_members")] public bool CanRemoveMembers { get; init; }
|
||||||
|
/// <summary>能删除任何人的消息和文件(所有者,或删除策略为 anyone)</summary>
|
||||||
|
[JsonPropertyName("can_delete_any")] public bool CanDeleteAny { get; init; }
|
||||||
|
/// <summary>能删除自己发的消息和文件</summary>
|
||||||
|
[JsonPropertyName("can_delete_own")] public bool CanDeleteOwn { get; init; }
|
||||||
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
|
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
|
||||||
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
|
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
|
||||||
/// <summary>空间头像(data URL,PNG/JPEG/WebP/GIF),没有设置时为 null。</summary>
|
/// <summary>头像版本标记,变了就要重新取头像(GET /channels/{id}/avatar);没有头像为 null。头像本体不随空间信息下发。</summary>
|
||||||
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
|
[JsonPropertyName("avatar_version")] public string? AvatarVersion { get; init; }
|
||||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||||
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
||||||
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
||||||
@@ -9,7 +9,6 @@ public sealed class ServerLimits
|
|||||||
public int MaxFramesPerItem { get; set; } = 65_536;
|
public int MaxFramesPerItem { get; set; } = 65_536;
|
||||||
public long MaxChannelBytes { get; set; } = 1L << 30;
|
public long MaxChannelBytes { get; set; } = 1L << 30;
|
||||||
public long MaxWorkspaceBytes { get; set; } = 1L << 40;
|
public long MaxWorkspaceBytes { get; set; } = 1L << 40;
|
||||||
public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30;
|
|
||||||
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
|
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
|
||||||
public int MaxMembers { get; set; } = 64;
|
public int MaxMembers { get; set; } = 64;
|
||||||
public int MaxVisitors { get; set; } = 10_000;
|
public int MaxVisitors { get; set; } = 10_000;
|
||||||
File renamed without changes.
@@ -66,3 +66,10 @@ public enum TombstoneReason : byte
|
|||||||
DownloadLimit = 3,
|
DownloadLimit = 3,
|
||||||
Superseded = 4,
|
Superseded = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>便捷分享的状态(线格式的取值)。前端从生成的契约里取,不手写字符串</summary>
|
||||||
|
public static class ShareState
|
||||||
|
{
|
||||||
|
public const string Draft = "draft";
|
||||||
|
public const string Sealed = "sealed";
|
||||||
|
}
|
||||||
@@ -30,6 +30,18 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>只有 26 字节头部(不带负载)的帧:文件服务存负载,主后端只收头部和回执。长度字段仍要合法。</summary>
|
||||||
|
public static bool TryParseHeader(ReadOnlySpan<byte> data, out Envelope envelope)
|
||||||
|
{
|
||||||
|
envelope = default;
|
||||||
|
if (data.Length != HeaderSize || data[0] != Version) return false;
|
||||||
|
if ((data[1] & 0xE0) != 0) return false;
|
||||||
|
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
|
||||||
|
if (payloadLen > int.MaxValue) return false;
|
||||||
|
envelope = new Envelope((FrameFlags)data[1], Id16.FromBytes(data.Slice(2, Id16.Size)), BinaryPrimitives.ReadUInt32BigEndian(data[18..]), (int)payloadLen);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsFinal => (Flags & FrameFlags.Final) != 0;
|
public bool IsFinal => (Flags & FrameFlags.Final) != 0;
|
||||||
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
|
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
|
||||||
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
|
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
|
||||||
@@ -22,6 +22,10 @@ public enum ErrorCode
|
|||||||
ChannelPasswordRequired,
|
ChannelPasswordRequired,
|
||||||
ChannelPasswordInvalid,
|
ChannelPasswordInvalid,
|
||||||
SpaceNameRequired,
|
SpaceNameRequired,
|
||||||
|
/// <summary>这个条目的内容在文件服务上:去要一张票据,直接找文件服务</summary>
|
||||||
|
UseFileApi,
|
||||||
|
/// <summary>文件服务暂时连不上或已满</summary>
|
||||||
|
FilesUnavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
|
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
|
||||||
@@ -63,6 +67,8 @@ public static class ErrorCodes
|
|||||||
ErrorCode.ChannelPasswordRequired => "channel_password_required",
|
ErrorCode.ChannelPasswordRequired => "channel_password_required",
|
||||||
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
|
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
|
||||||
ErrorCode.SpaceNameRequired => "space_name_required",
|
ErrorCode.SpaceNameRequired => "space_name_required",
|
||||||
|
ErrorCode.UseFileApi => "use_file_api",
|
||||||
|
ErrorCode.FilesUnavailable => "files_unavailable",
|
||||||
_ => "bad_request",
|
_ => "bad_request",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,6 +85,8 @@ public static class ErrorCodes
|
|||||||
ErrorCode.TooLarge => 413,
|
ErrorCode.TooLarge => 413,
|
||||||
ErrorCode.CaptchaRequired => 428,
|
ErrorCode.CaptchaRequired => 428,
|
||||||
ErrorCode.CaptchaUnavailable => 503,
|
ErrorCode.CaptchaUnavailable => 503,
|
||||||
|
ErrorCode.FilesUnavailable => 503,
|
||||||
|
ErrorCode.UseFileApi => 409,
|
||||||
ErrorCode.RateLimited => 429,
|
ErrorCode.RateLimited => 429,
|
||||||
ErrorCode.VisitorsFull => 403,
|
ErrorCode.VisitorsFull => 403,
|
||||||
ErrorCode.AccountRequired => 401,
|
ErrorCode.AccountRequired => 401,
|
||||||
@@ -25,6 +25,15 @@ public readonly struct Id16 : IEquatable<Id16>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>只接受规范的 22 字符 base64url,拒绝同一值的非规范编码。</summary>
|
/// <summary>只接受规范的 22 字符 base64url,拒绝同一值的非规范编码。</summary>
|
||||||
|
/// <summary>32 个十六进制字符(文件服务和 blob 键里的写法)</summary>
|
||||||
|
public static bool TryParseHex(string? hex, out Id16 id)
|
||||||
|
{
|
||||||
|
id = default;
|
||||||
|
if (hex is not { Length: 32 }) return false;
|
||||||
|
try { id = FromBytes(Convert.FromHexString(hex)); return true; }
|
||||||
|
catch (FormatException) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
public static bool TryParse(ReadOnlySpan<char> text, out Id16 id)
|
public static bool TryParse(ReadOnlySpan<char> text, out Id16 id)
|
||||||
{
|
{
|
||||||
id = default;
|
id = default;
|
||||||
@@ -84,6 +84,8 @@ public sealed record Item(
|
|||||||
|
|
||||||
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
|
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
|
||||||
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
|
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
|
||||||
|
public sealed record MemberView(ChannelMember Member, bool CanRemove);
|
||||||
|
public sealed record InvitationView(ChannelInvitation Invitation, bool CanRevoke);
|
||||||
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
|
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
|
||||||
|
|
||||||
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
|
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
namespace WebDrop.Core;
|
||||||
|
|
||||||
|
/// <summary>给客户端的访问凭证:文件服务地址、文件域 id 和一张短期票据</summary>
|
||||||
|
public sealed record FileGrant(string Url, string Domain, string Ticket, long ExpiresAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主后端只负责「权限」:判断某人在某个空间里能不能读写某个频道的文件,然后给文件服务(fileAPI)签一张短期票据。
|
||||||
|
/// 文件域的创建、容量、用量、字节的存取都是文件服务自己的事,主后端既不调用它,也不保存它的任何状态。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileGrants
|
||||||
|
{
|
||||||
|
bool Enabled { get; }
|
||||||
|
/// <summary>文件服务对外的地址,告诉客户端去哪里</summary>
|
||||||
|
string PublicUrl { get; }
|
||||||
|
/// <summary>space:签发票据的空间 id(十六进制)。scope:Create 时是空间 id;其余是文件域 id。item 是条目 id(十六进制)或 "*"</summary>
|
||||||
|
FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NoFileGrants : IFileGrants
|
||||||
|
{
|
||||||
|
public static readonly NoFileGrants Instance = new();
|
||||||
|
public bool Enabled => false;
|
||||||
|
public string PublicUrl => "";
|
||||||
|
public FileGrant Issue(FileOp op, string space, string scope, string item, uint from, long nowMs) => throw new WebDropException(ErrorCode.NotFound);
|
||||||
|
}
|
||||||
+1
-1
@@ -355,7 +355,7 @@ public sealed class ShareService(
|
|||||||
private static ShareInfo ToInfo(Share share, IReadOnlyList<ShareItem> items) => new()
|
private static ShareInfo ToInfo(Share share, IReadOnlyList<ShareItem> items) => new()
|
||||||
{
|
{
|
||||||
Id = share.Id.ToBase64Url(),
|
Id = share.Id.ToBase64Url(),
|
||||||
State = share.IsSealed ? "sealed" : "draft",
|
State = share.IsSealed ? ShareState.Sealed : ShareState.Draft,
|
||||||
ExpiresAt = share.ExpiresAt,
|
ExpiresAt = share.ExpiresAt,
|
||||||
CreatedAt = share.CreatedAt,
|
CreatedAt = share.CreatedAt,
|
||||||
SealedAt = share.SealedAt,
|
SealedAt = share.SealedAt,
|
||||||
+14
-21
@@ -27,8 +27,7 @@ public sealed partial class ChannelService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var recipients = crypto.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
|
var recipients = ChannelPolicy.Readers(crypto, channelId, domain).Where(a => (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
|
||||||
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
|
|
||||||
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().Order(StringComparer.Ordinal).ToList();
|
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().Order(StringComparer.Ordinal).ToList();
|
||||||
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
||||||
batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = currentEpoch + 1, Recipients = recipients, Rotate = true });
|
batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = currentEpoch + 1, Recipients = recipients, Rotate = true });
|
||||||
@@ -67,8 +66,7 @@ public sealed partial class ChannelService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var recipients = state.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
|
var recipients = ChannelPolicy.Readers(state, channelId, domain).Where(a => (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
|
||||||
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
|
|
||||||
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().ToHashSet(StringComparer.Ordinal);
|
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().ToHashSet(StringComparer.Ordinal);
|
||||||
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
|
||||||
expected.AddRange(recipients.Select(recipient => (domain, currentEpoch + 1, recipient)));
|
expected.AddRange(recipients.Select(recipient => (domain, currentEpoch + 1, recipient)));
|
||||||
@@ -111,38 +109,34 @@ public sealed partial class ChannelService
|
|||||||
if (crypto.Envelopes.Count == 0 && crypto.Objects.Count == 0 && space.Mode == ChannelMode.Enc) return true;
|
if (crypto.Envelopes.Count == 0 && crypto.Objects.Count == 0 && space.Mode == ChannelMode.Enc) return true;
|
||||||
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
|
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
|
||||||
if (item is null) return false;
|
if (item is null) return false;
|
||||||
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return true;
|
if (ChannelPolicy.IsSpaceOwner(space, principal)) return true;
|
||||||
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
|
if (!ChannelPolicy.CanReadObject(ChannelPolicy.AccessOf(crypto, principal.AccountName, item.ChannelId), item.Domain, item.CreatedRevision)) return false;
|
||||||
if (access is null || (item.Domain == CryptoDomain.Chat ? !access.ChatRead || item.CreatedRevision < access.ChatFromRevision : !access.FilesRead || item.CreatedRevision < access.FilesFromRevision)) return false;
|
|
||||||
if (space.Mode == ChannelMode.Raw) return true;
|
if (space.Mode == ChannelMode.Raw) return true;
|
||||||
return crypto.Envelopes.Exists(e => e.ChannelId == item.ChannelId && e.Domain == item.Domain && e.Epoch == item.Epoch && e.Username == principal.AccountName);
|
return crypto.Envelopes.Exists(e => e.ChannelId == item.ChannelId && e.Domain == item.Domain && e.Epoch == item.Epoch && e.Username == principal.AccountName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Envelope envelope)
|
private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Id16 itemId)
|
||||||
{
|
{
|
||||||
var crypto = tx.GetWorkspaceCrypto(space.Id);
|
var crypto = tx.GetWorkspaceCrypto(space.Id);
|
||||||
if (space.Mode == ChannelMode.Enc && crypto.Envelopes.Count == 0) return;
|
if (!ChannelPolicy.GrantsEnforced(space, crypto)) return;
|
||||||
if (space.Mode == ChannelMode.Raw && crypto.Access.Count == 0 && crypto.Objects.Count == 0) return;
|
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||||
var item = crypto.Objects.Find(o => o.ItemId == envelope.ItemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest);
|
|
||||||
if (item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
|
if (item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
var channel = tx.GetWorkspace(space.Id)?.Channels.Find(c => c.Id == item.ChannelId);
|
var channel = tx.GetWorkspace(space.Id)?.Channels.Find(c => c.Id == item.ChannelId);
|
||||||
if (space.Mode == ChannelMode.Enc && channel is not null && item.Epoch != (item.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
|
if (space.Mode == ChannelMode.Enc && channel is not null && item.Epoch != (item.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
|
||||||
if (principal.Role != Role.Owner)
|
if (!ChannelPolicy.IsOwner(principal))
|
||||||
{
|
{
|
||||||
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
|
var access = ChannelPolicy.AccessOf(crypto, principal.AccountName, item.ChannelId);
|
||||||
if (access is null && (space.Mode == ChannelMode.Enc || crypto.Access.Count > 0)
|
if (access is null ? space.Mode == ChannelMode.Enc || crypto.Access.Count > 0 : !ChannelPolicy.CanWriteDomain(access, item.Domain)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
|| access is not null && (item.Domain == CryptoDomain.Chat ? !access.ChatWrite : !access.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
|
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
|
||||||
{
|
{
|
||||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||||
var state = tx.GetWorkspaceCrypto(id);
|
var state = tx.GetWorkspaceCrypto(id);
|
||||||
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state;
|
if (ChannelPolicy.IsSpaceOwner(space, principal)) return state;
|
||||||
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
|
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||||
var allowed = access.ToDictionary(a => a.ChannelId);
|
var allowed = access.ToDictionary(a => a.ChannelId);
|
||||||
bool CanRead(string channelId, CryptoDomain domain) => allowed.TryGetValue(channelId, out var grant)
|
bool CanRead(string channelId, CryptoDomain domain) => ChannelPolicy.CanReadDomain(allowed.GetValueOrDefault(channelId), domain);
|
||||||
&& (domain == CryptoDomain.Chat ? grant.ChatRead : grant.FilesRead);
|
|
||||||
var envelopes = state.Envelopes.Where(e => e.Username == principal.AccountName && CanRead(e.ChannelId, e.Domain)).ToList();
|
var envelopes = state.Envelopes.Where(e => e.Username == principal.AccountName && CanRead(e.ChannelId, e.Domain)).ToList();
|
||||||
var epochs = envelopes.Select(e => (e.ChannelId, e.Domain, e.Epoch)).ToHashSet();
|
var epochs = envelopes.Select(e => (e.ChannelId, e.Domain, e.Epoch)).ToHashSet();
|
||||||
return new CryptoState { Envelopes = envelopes,
|
return new CryptoState { Envelopes = envelopes,
|
||||||
@@ -183,10 +177,9 @@ public sealed partial class ChannelService
|
|||||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
var state = tx.GetWorkspaceCrypto(id);
|
var state = tx.GetWorkspaceCrypto(id);
|
||||||
if (principal.Role != Role.Owner && (space.Mode == ChannelMode.Enc || state.Access.Count > 0))
|
if (!ChannelPolicy.IsOwner(principal) && (space.Mode == ChannelMode.Enc || state.Access.Count > 0))
|
||||||
{
|
{
|
||||||
var access = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
|
if (!ChannelPolicy.CanWriteDomain(ChannelPolicy.AccessOf(state, principal.AccountName, req.ChannelId), req.Domain)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
if (access is null || req.Domain == CryptoDomain.Chat && !access.ChatWrite || req.Domain == CryptoDomain.Files && !access.FilesUpload) throw new WebDropException(ErrorCode.Forbidden);
|
|
||||||
}
|
}
|
||||||
if (space.Mode == ChannelMode.Enc && req.Epoch != (req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
|
if (space.Mode == ChannelMode.Enc && req.Epoch != (req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
|
||||||
var existing = state.Objects.Find(o => o.ItemId == req.ItemId);
|
var existing = state.Objects.Find(o => o.ItemId == req.ItemId);
|
||||||
+25
-20
@@ -14,7 +14,7 @@ public sealed partial class ChannelService
|
|||||||
{
|
{
|
||||||
var channel = tx.GetChannel(id)!;
|
var channel = tx.GetChannel(id)!;
|
||||||
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
|
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
|
||||||
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatar(id, thumb: true));
|
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username), tx.GetChannelAvatarVersion(id), tx.GetWorkspace(id));
|
||||||
}).ToList());
|
}).ToList());
|
||||||
private static WorkspaceState NewWorkspace(string username, long now)
|
private static WorkspaceState NewWorkspace(string username, long now)
|
||||||
{
|
{
|
||||||
@@ -52,13 +52,8 @@ public sealed partial class ChannelService
|
|||||||
{
|
{
|
||||||
var crypto = tx.GetWorkspaceCrypto(space.Id);
|
var crypto = tx.GetWorkspaceCrypto(space.Id);
|
||||||
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
|
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||||
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
|
var ids = grants.Where(ChannelPolicy.CanSeeChannel).Select(a => a.ChannelId).ToHashSet();
|
||||||
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
|
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
|
||||||
foreach (var workspaceChannel in state.Channels)
|
|
||||||
{
|
|
||||||
var grant = grants.Find(a => a.ChannelId == workspaceChannel.Id);
|
|
||||||
if (grant is null || !grant.FilesRead && !grant.FilesUpload) workspaceChannel.FileBytes = 0;
|
|
||||||
}
|
|
||||||
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
|
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
|
||||||
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
|
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
|
||||||
}
|
}
|
||||||
@@ -73,8 +68,8 @@ public sealed partial class ChannelService
|
|||||||
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
|
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||||
EnsureDefaultRole(current, clock.NowMs);
|
EnsureDefaultRole(current, clock.NowMs);
|
||||||
// 被授予对应权限的身份组成员可以代行,不要求频道开放成员上传
|
// 被授予对应权限的身份组成员可以代行,不要求频道开放成员上传
|
||||||
bool delegated = permission is not null && principal.Role != Role.Owner && HasWorkspacePermission(current, principal, permission);
|
bool delegated = permission is not null && !ChannelPolicy.IsOwner(principal) && ChannelPolicy.HasPermission(current, principal, permission);
|
||||||
if (requireAccount && principal.Role != Role.Owner && !delegated) throw new WebDropException(ErrorCode.Forbidden);
|
if (requireAccount && !ChannelPolicy.IsOwner(principal) && !delegated) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
if (!delegated && !ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
if (!delegated && !ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
|
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
|
||||||
change(tx, current, principal);
|
change(tx, current, principal);
|
||||||
@@ -87,17 +82,33 @@ public sealed partial class ChannelService
|
|||||||
events.SettingsChanged(id);
|
events.SettingsChanged(id);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId)
|
/// <summary>文件域的引用只是个不透明的字符串(文件服务给的域 id),主后端不解释它,只存下来、原样给成员</summary>
|
||||||
|
private static string? FileDomainRef(string? value)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return null;
|
||||||
|
if (value.Length > 64 || !value.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_')) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId, string? fileDomain = null)
|
||||||
|
{
|
||||||
|
string? domain = FileDomainRef(fileDomain);
|
||||||
WorkspaceChannel? created = null;
|
WorkspaceChannel? created = null;
|
||||||
var state = ChangeWorkspace(id, creds, (_, workspace, principal) =>
|
var state = ChangeWorkspace(id, creds, (_, workspace, principal) =>
|
||||||
{
|
{
|
||||||
created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName! };
|
created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName!, FileDomain = domain };
|
||||||
workspace.Channels.Add(created);
|
workspace.Channels.Add(created);
|
||||||
});
|
});
|
||||||
return (state, created!);
|
return (state, created!);
|
||||||
}
|
}
|
||||||
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
|
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
|
||||||
|
/// <summary>给还没有文件域的频道指定一个(创建频道时没带,或旧频道)。指定后不能改:文件已经存在那里了</summary>
|
||||||
|
public WorkspaceState SetWorkspaceChannelFileDomain(Id16 id, Credentials creds, string channelId, string fileDomain) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||||
|
{
|
||||||
|
var channel = FindChannel(state, channelId);
|
||||||
|
string domain = FileDomainRef(fileDomain) ?? throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
if (channel.FileDomain is not null && channel.FileDomain != domain) throw new WebDropException(ErrorCode.Conflict);
|
||||||
|
channel.FileDomain = domain;
|
||||||
|
}, permission: WorkspacePermission.ManageStructure);
|
||||||
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||||
FindChannel(state, channelId).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure);
|
FindChannel(state, channelId).Name = WorkspaceName(name), permission: WorkspacePermission.ManageStructure);
|
||||||
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
|
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
|
||||||
@@ -123,9 +134,6 @@ public sealed partial class ChannelService
|
|||||||
state.Groups.Remove(group);
|
state.Groups.Remove(group);
|
||||||
}, permission: WorkspacePermission.ManageStructure);
|
}, permission: WorkspacePermission.ManageStructure);
|
||||||
// ───── 身份组(只有空间主能增删改、分配成员) ─────
|
// ───── 身份组(只有空间主能增删改、分配成员) ─────
|
||||||
public static bool HasWorkspacePermission(WorkspaceState? state, Principal principal, string permission) =>
|
|
||||||
principal.Role == Role.Owner
|
|
||||||
|| principal.AccountName is { } account && state?.Roles.Any(r => r.Members.Contains(account) && r.Permissions.Contains(permission)) == true;
|
|
||||||
private static string? RoleLabel(string? value)
|
private static string? RoleLabel(string? value)
|
||||||
{
|
{
|
||||||
value = value?.Trim();
|
value = value?.Trim();
|
||||||
@@ -193,17 +201,14 @@ public sealed partial class ChannelService
|
|||||||
if (!state.Channels.Exists(c => c.Id == threadId)) throw new WebDropException(ErrorCode.NotFound);
|
if (!state.Channels.Exists(c => c.Id == threadId)) throw new WebDropException(ErrorCode.NotFound);
|
||||||
var space = tx.GetChannel(id)!; var crypto = tx.GetWorkspaceCrypto(id);
|
var space = tx.GetChannel(id)!; var crypto = tx.GetWorkspaceCrypto(id);
|
||||||
// 与 CheckObjectWrite 同一口径:加密空间或已有授权记录时,必须有目的频道的写入授权
|
// 与 CheckObjectWrite 同一口径:加密空间或已有授权记录时,必须有目的频道的写入授权
|
||||||
if (principal.Role != Role.Owner && principal.AccountName != space.CreatorAccount && (space.Mode == ChannelMode.Enc ? crypto.Envelopes.Count > 0 : crypto.Access.Count > 0 || crypto.Objects.Count > 0))
|
if (!ChannelPolicy.IsSpaceOwner(space, principal) && ChannelPolicy.GrantsEnforced(space, crypto)
|
||||||
{
|
&& !ChannelPolicy.CanWriteAnyDomain(ChannelPolicy.AccessOf(crypto, principal.AccountName, threadId))) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
var grant = crypto.Access.Find(a => a.ChannelId == threadId && a.Username == principal.AccountName);
|
|
||||||
if (grant is null || !(grant.ChatWrite || grant.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden);
|
|
||||||
}
|
|
||||||
foreach (var itemId in itemIds)
|
foreach (var itemId in itemIds)
|
||||||
{
|
{
|
||||||
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
|
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
|
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||||
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
|
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
if (principal.Role != Role.Owner && item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
|
if (!ChannelPolicy.IsOwner(principal) && item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
|
||||||
state.Locations.RemoveAll(l => l.ItemId == itemId);
|
state.Locations.RemoveAll(l => l.ItemId == itemId);
|
||||||
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
|
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
|
||||||
}
|
}
|
||||||
File renamed without changes.
@@ -8,7 +8,8 @@ public sealed class WorkspaceChannel
|
|||||||
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
|
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
|
||||||
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
|
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
|
||||||
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
|
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
|
||||||
[JsonPropertyName("fileBytes")] public long FileBytes { get; set; }
|
/// <summary>这个频道的文件域在文件服务里的 id。不透明字符串:创建、容量、用量都归文件服务管,这里只是引用。null 表示还没分配</summary>
|
||||||
|
[JsonPropertyName("fileDomain")] public string? FileDomain { get; set; }
|
||||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||||
[JsonPropertyName("name")] public required string Name { get; set; }
|
[JsonPropertyName("name")] public required string Name { get; set; }
|
||||||
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
|
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
|
||||||
File renamed without changes.
@@ -6,4 +6,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\WebDrop.FileProtocol\WebDrop.FileProtocol.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
namespace WebDrop.FileApi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件域的登记:一个域一个小 JSON 文件({数据目录}/domains/{id}.json),记录它属于哪个空间、分配了多大。
|
||||||
|
/// 域 id 是文件服务随机生成的,和主后端的任何 id 无关;用了多少字节由 <see cref="FrameStore"/> 按顶层目录统计。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DomainStore
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
|
public DomainStore(string root)
|
||||||
|
{
|
||||||
|
_root = Path.GetFullPath(root);
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string PathOf(string id) => Path.Combine(_root, id + ".json");
|
||||||
|
|
||||||
|
public FileDomainBody Create(string space, long quota, long nowMs)
|
||||||
|
{
|
||||||
|
var domain = new FileDomainBody(Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), space, quota, 0, nowMs);
|
||||||
|
string tmp = PathOf(domain.Id) + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||||
|
File.WriteAllText(tmp, JsonSerializer.Serialize(domain, FileProtocolJson.Default.FileDomainBody));
|
||||||
|
File.Move(tmp, PathOf(domain.Id), overwrite: false);
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileDomainBody? Get(string id)
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id)) return null;
|
||||||
|
try { return JsonSerializer.Deserialize(File.ReadAllText(PathOf(id)), FileProtocolJson.Default.FileDomainBody); }
|
||||||
|
catch (Exception e) when (e is IOException or JsonException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Delete(string id)
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id) || !File.Exists(PathOf(id))) return false;
|
||||||
|
File.Delete(PathOf(id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
namespace WebDrop.FileApi;
|
||||||
|
|
||||||
|
public static class FileApiApp
|
||||||
|
{
|
||||||
|
public static WebApplication Build(string[] args, Action<WebApplicationBuilder>? configure = null)
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateSlimBuilder(args);
|
||||||
|
configure?.Invoke(builder);
|
||||||
|
var options = builder.Configuration.GetSection("FileApi").Get<FileApiOptions>() ?? new FileApiOptions();
|
||||||
|
if (options.Secret.Length < 32) throw new InvalidOperationException("FileApi:Secret 至少 32 个字符(和主后端的 WebDrop:Files:Secret 一致)");
|
||||||
|
if (Array.IndexOf(options.EffectiveChoices, options.DefaultQuota) < 0) throw new InvalidOperationException("FileApi:DefaultQuota 必须是 FileApi:QuotaChoices 里的一档");
|
||||||
|
|
||||||
|
builder.WebHost.ConfigureKestrel(k =>
|
||||||
|
{
|
||||||
|
k.AddServerHeader = false;
|
||||||
|
k.Limits.MaxRequestBodySize = options.MaxFrameBytes;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton(options);
|
||||||
|
builder.Services.AddSingleton(new FrameStore(Path.Combine(options.DataDir, "frames"), options.MaxStoreBytes, Path.Combine(options.DataDir, "sync")));
|
||||||
|
var domains = new DomainStore(Path.Combine(options.DataDir, "domains"));
|
||||||
|
builder.Services.AddSingleton(domains);
|
||||||
|
builder.Services.AddHostedService<PendingSweeper>();
|
||||||
|
builder.Services.AddSingleton<SyncWorker>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncWorker>());
|
||||||
|
if (options.CorsOrigins.Length > 0)
|
||||||
|
{
|
||||||
|
builder.Services.AddCors(c => c.AddDefaultPolicy(p => p
|
||||||
|
.WithOrigins(options.CorsOrigins)
|
||||||
|
.WithMethods("GET", "PUT", "POST", "DELETE", "OPTIONS")
|
||||||
|
.WithHeaders(FileWire.TicketHeader, "Content-Type")
|
||||||
|
.WithExposedHeaders("Retry-After")
|
||||||
|
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
|
||||||
|
}
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.Use(async (ctx, next) =>
|
||||||
|
{
|
||||||
|
var h = ctx.Response.Headers;
|
||||||
|
h["Referrer-Policy"] = "no-referrer";
|
||||||
|
h["X-Content-Type-Options"] = "nosniff";
|
||||||
|
h["Cache-Control"] = "no-store";
|
||||||
|
h["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";
|
||||||
|
try { await next(ctx); }
|
||||||
|
catch (OperationCanceledException) when (ctx.RequestAborted.IsCancellationRequested) { }
|
||||||
|
catch (BadHttpRequestException e)
|
||||||
|
{
|
||||||
|
if (!ctx.Response.HasStarted) { ctx.Response.StatusCode = e.StatusCode; await ctx.Response.WriteAsync("{\"error\":\"bad_request\"}"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (options.CorsOrigins.Length > 0) app.UseCors();
|
||||||
|
Map(app, options, app.Services.GetRequiredService<FrameStore>(), domains);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Map(WebApplication app, FileApiOptions options, FrameStore store, DomainStore domains)
|
||||||
|
{
|
||||||
|
byte[] secret = Encoding.UTF8.GetBytes(options.Secret);
|
||||||
|
|
||||||
|
// 校验票据,规则只在这里:文件域、条目必须和路径一致;票据必须是这个域所属的空间签发的;
|
||||||
|
// 读取、删除的票据只能用一次(下载次数在主后端签票据时计,重放同一张票据等于绕过计次);管理票据(运维用)对任何条目有效
|
||||||
|
var usedTickets = new UsedTickets();
|
||||||
|
FileTicket? Authorize(HttpContext ctx, FileOp op, string channel, string? item)
|
||||||
|
{
|
||||||
|
if (!FileTokens.TryVerify(secret, ctx.Request.Headers[FileWire.TicketHeader].ToString(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), out FileTicket ticket)) return null;
|
||||||
|
if (ticket.Op == FileOp.Admin) return ticket;
|
||||||
|
if (!(ticket.Op == op && ticket.Channel == channel && (item is null || ticket.Item == item))) return null;
|
||||||
|
// 票据是哪个空间签的,必须就是域所属的那个空间(别的空间即使拿到域 id 也用不了)
|
||||||
|
if (domains.Get(channel) is not { } domain || domain.Space != ticket.Space) return null;
|
||||||
|
if (op is FileOp.Get or FileOp.Delete && !usedTickets.TryUse(ticket.Jti, ticket.ExpiresMs)) return null;
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
IResult Error(int status, string code) => Results.Text($"{{\"error\":\"{code}\"}}", "application/json", Encoding.UTF8, status);
|
||||||
|
|
||||||
|
app.MapGet("/healthz", () => Results.Text("ok"));
|
||||||
|
|
||||||
|
// 文件域的选项(创建时可选的容量档位):不含任何机密,谁都能读,前端创建文件域前来看
|
||||||
|
app.MapGet("/file/v1/options", () => Results.Text(System.Text.Json.JsonSerializer.Serialize(options.Domain, FileProtocolJson.Default.FileDomainOptions), "application/json"));
|
||||||
|
|
||||||
|
app.MapGet("/file/v1/info", (HttpContext ctx) =>
|
||||||
|
FileTokens.TryVerify(secret, ctx.Request.Headers[FileWire.TicketHeader].ToString(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), out FileTicket t) && t.Op == FileOp.Admin
|
||||||
|
? Results.Text($"{{\"used_bytes\":{store.UsedBytes},\"free_bytes\":{store.FreeBytes}}}", "application/json")
|
||||||
|
: Error(401, "unauthorized"));
|
||||||
|
|
||||||
|
// 存一帧:请求体是完整的帧封装。域必须存在,写入受域的容量限制。返回这一帧的摘要,客户端拿它去主后端登记
|
||||||
|
app.MapPut("/file/v1/domains/{id}/items/{item}/frames/{index:long}", async (string id, string item, long index, HttpContext ctx, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item) || index is < 0 or > uint.MaxValue) return Error(400, "bad_request");
|
||||||
|
if (Authorize(ctx, FileOp.Put, id, item) is null) return Error(401, "unauthorized");
|
||||||
|
if (domains.Get(id) is not { } domain) return Error(404, "not_found");
|
||||||
|
if (ctx.Request.ContentLength is not { } length || length < FrameHeader.Size || length > options.MaxFrameBytes) return Error(413, "too_large");
|
||||||
|
|
||||||
|
byte[] frame = new byte[length];
|
||||||
|
await ctx.Request.Body.ReadExactlyAsync(frame, ct).ConfigureAwait(false);
|
||||||
|
if (!FrameHeader.TryParse(frame, out var header) || header.TotalLength != frame.Length || header.ItemHex != item || header.Index != index)
|
||||||
|
return Error(400, "bad_request");
|
||||||
|
|
||||||
|
byte[] digest = SHA256.HashData(frame);
|
||||||
|
switch (await store.PutAsync(id, item, (uint)index, frame, digest, ct, domain.Quota).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
case PutResult.Conflict: return Error(409, "conflict");
|
||||||
|
case PutResult.Full: return Error(507, "store_full");
|
||||||
|
case PutResult.OverQuota: return Error(507, "quota_exceeded");
|
||||||
|
}
|
||||||
|
return Results.Text($"{{\"digest\":\"{Convert.ToHexStringLower(digest)}\",\"length\":{frame.Length}}}", "application/json", Encoding.UTF8, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 读取:自 from 起把连续的帧原样顺序写出
|
||||||
|
app.MapGet("/file/v1/domains/{id}/items/{item}/frames", async (string id, string item, HttpContext ctx, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item)) return Error(400, "bad_request");
|
||||||
|
var ticket = Authorize(ctx, FileOp.Get, id, item);
|
||||||
|
if (ticket is null) return Error(401, "unauthorized");
|
||||||
|
if (!uint.TryParse(ctx.Request.Query["from"], out uint from)) from = ticket.From;
|
||||||
|
if (ticket.Op != FileOp.Admin && from < ticket.From) return Error(403, "forbidden");
|
||||||
|
|
||||||
|
ctx.Response.ContentType = "application/octet-stream";
|
||||||
|
bool any = false;
|
||||||
|
foreach (var path in store.FramesFrom(id, item, from))
|
||||||
|
{
|
||||||
|
any = true;
|
||||||
|
await ctx.Response.SendFileAsync(path, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return any ? Results.Empty : Error(404, "not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删一个条目的所有帧:删除票据(主后端确认调用者有权删除之后签发),或者上传这个条目的票据(取消上传时清掉自己传的字节)
|
||||||
|
app.MapDelete("/file/v1/domains/{id}/items/{item}", (string id, string item, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item)) return Error(400, "bad_request");
|
||||||
|
if (Authorize(ctx, FileOp.Delete, id, item) is null && Authorize(ctx, FileOp.Put, id, item) is null) return Error(401, "unauthorized");
|
||||||
|
return store.DeletePrefix($"{id}/{item}") ? Results.NoContent() : Error(400, "bad_request");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确认一个条目:整个文件已经登记到主后端。持上传这个条目的票据
|
||||||
|
app.MapPost("/file/v1/domains/{id}/items/{item}/commit", (string id, string item, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id) || !FileWire.IsHex32(item)) return Error(400, "bad_request");
|
||||||
|
if (Authorize(ctx, FileOp.Put, id, item) is null) return Error(401, "unauthorized");
|
||||||
|
return store.Commit(id, item) ? Results.NoContent() : Error(404, "not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───── 文件域:创建、查询、删除。前端带着空间签发的票据直接来这里
|
||||||
|
// 创建:票据必须是 Create(空间的所有者/管理员向主后端申请的),Channel 字段是空间 id
|
||||||
|
app.MapPost("/file/v1/domains", async (HttpContext ctx, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (!FileTokens.TryVerify(secret, ctx.Request.Headers[FileWire.TicketHeader].ToString(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), out FileTicket ticket)
|
||||||
|
|| ticket.Op is not (FileOp.Create or FileOp.Admin)) return Error(401, "unauthorized");
|
||||||
|
if (!FileWire.IsHex32(ticket.Channel)) return Error(400, "bad_request");
|
||||||
|
var request = ctx.Request.ContentLength is 0 ? new FileDomainCreate() : await ctx.Request.ReadFromJsonAsync(FileProtocolJson.Default.FileDomainCreate, ct).ConfigureAwait(false) ?? new FileDomainCreate();
|
||||||
|
long quota = request.Quota == 0 ? options.DefaultQuota : request.Quota;
|
||||||
|
if (Array.IndexOf(options.EffectiveChoices, quota) < 0) return Error(400, "bad_quota");
|
||||||
|
var created = domains.Create(ticket.Channel, quota, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
return Results.Text(System.Text.Json.JsonSerializer.Serialize(created, FileProtocolJson.Default.FileDomainBody), "application/json", Encoding.UTF8, 201);
|
||||||
|
});
|
||||||
|
// 查询:持有这个域的读、写或管理票据都可以
|
||||||
|
app.MapGet("/file/v1/domains/{id}", (string id, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id)) return Error(400, "bad_request");
|
||||||
|
if (Authorize(ctx, FileOp.Get, id, null) is null && Authorize(ctx, FileOp.Put, id, null) is null) return Error(401, "unauthorized");
|
||||||
|
if (domains.Get(id) is not { } domain) return Error(404, "not_found");
|
||||||
|
return Results.Text(System.Text.Json.JsonSerializer.Serialize(domain with { Used = store.UsedBy(id) }, FileProtocolJson.Default.FileDomainBody), "application/json");
|
||||||
|
});
|
||||||
|
// 删除域和里面所有文件:只有管理票据(运维用)
|
||||||
|
app.MapDelete("/file/v1/domains/{id}", (string id, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
if (!FileWire.IsHex32(id)) return Error(400, "bad_request");
|
||||||
|
if (Authorize(ctx, FileOp.Admin, "*", null) is null) return Error(401, "unauthorized");
|
||||||
|
if (!domains.Delete(id)) return Error(404, "not_found");
|
||||||
|
store.DeletePrefix(id);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using WebDrop.FileProtocol;
|
||||||
|
using WebDrop.Sync;
|
||||||
|
|
||||||
|
namespace WebDrop.FileApi;
|
||||||
|
|
||||||
|
/// <summary>配置节 "FileApi"。环境变量形式:FileApi__DataDir、FileApi__Secret ……</summary>
|
||||||
|
public sealed class FileApiOptions
|
||||||
|
{
|
||||||
|
public string DataDir { get; set; } = "files-data";
|
||||||
|
|
||||||
|
/// <summary>和主后端共用的密钥(主后端的 WebDrop:Files:Secret),至少 32 个字符</summary>
|
||||||
|
public string Secret { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>前端所在的源;浏览器直接把文件传给这里,必须列出,否则不发 CORS 头</summary>
|
||||||
|
public string[] CorsOrigins { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>创建频道时可以分配的文件空间档位(字节);不配置用内置的 1/5/10/50/100 GiB。默认值不放在数组里:配置绑定会把数组按下标合并进默认值</summary>
|
||||||
|
public long[] QuotaChoices { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>创建频道时不选择的默认文件空间,必须是档位之一</summary>
|
||||||
|
public long DefaultQuota { get; set; } = 10L << 30;
|
||||||
|
|
||||||
|
public FileDomainOptions Domain => new(EffectiveChoices, DefaultQuota);
|
||||||
|
|
||||||
|
public long[] EffectiveChoices => QuotaChoices.Length == 0 ? FileDomainOptions.Fallback.QuotaChoices : QuotaChoices.Where(n => n > 0).Distinct().Order().ToArray();
|
||||||
|
|
||||||
|
/// <summary>上传了字节、却一直没有被确认(没有登记到主后端)的条目,过多少小时删掉。默认 24</summary>
|
||||||
|
public int PendingRetentionHours { get; set; } = 24;
|
||||||
|
|
||||||
|
/// <summary>磁盘上最多存这么多字节,0 表示不限。满了拒绝新的帧(507),已有的不受影响</summary>
|
||||||
|
public long MaxStoreBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>一帧的最大字节数:26 字节头部 + 最大负载(1 MiB + 16 字节标签)</summary>
|
||||||
|
public int MaxFrameBytes { get; set; } = 26 + (1 << 20) + 16;
|
||||||
|
|
||||||
|
/// <summary>每天把新增的文件帧同步到外部存储(S3 协议)。已删除的内容在备份里再留 RetainDays 天</summary>
|
||||||
|
public DailyBackupOptions Backup { get; set; } = new() { Time = "04:30" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
namespace WebDrop.FileApi;
|
||||||
|
|
||||||
|
public enum PutResult { Created, Existing, Conflict, Full, OverQuota }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件帧的磁盘存储:{根}/{频道}/{条目}/{帧序号}。一帧一个文件,先写临时文件再改名,读者看不到半个文件。
|
||||||
|
/// 同一个序号只能存一份内容:再传同样的内容是幂等的,不同的内容拒绝(帧一旦存下就不可变)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FrameStore
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly string _syncRoot;
|
||||||
|
private readonly long _maxBytes;
|
||||||
|
private long _used;
|
||||||
|
|
||||||
|
/// <param name="syncRoot">同步状态:每个已经同步到外部存储的帧在这里有一个空的标记文件,不放在帧目录里(那里的内容就是帧本身)</param>
|
||||||
|
public FrameStore(string root, long maxBytes, string? syncRoot = null)
|
||||||
|
{
|
||||||
|
_root = Path.GetFullPath(root);
|
||||||
|
_syncRoot = Path.GetFullPath(syncRoot ?? _root + ".sync");
|
||||||
|
_maxBytes = maxBytes;
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
Directory.CreateDirectory(_syncRoot);
|
||||||
|
_used = Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".tmp", StringComparison.Ordinal)).Sum(p => new FileInfo(p).Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long UsedBytes => Interlocked.Read(ref _used);
|
||||||
|
|
||||||
|
// 每个顶层目录(文件域)已用的字节数,第一次用到时扫一遍磁盘,之后随写入、删除增减
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long[]> _usedBy = new();
|
||||||
|
|
||||||
|
private long[] CounterOf(string channelHex) => _usedBy.GetOrAdd(channelHex, key =>
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(_root, key);
|
||||||
|
return [Directory.Exists(dir) ? Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".tmp", StringComparison.Ordinal)).Sum(p => new FileInfo(p).Length) : 0];
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>一个文件域(顶层目录)已经存了多少字节</summary>
|
||||||
|
public long UsedBy(string channelHex) => Interlocked.Read(ref CounterOf(channelHex)[0]);
|
||||||
|
|
||||||
|
public long FreeBytes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
try { return new DriveInfo(Path.GetPathRoot(_root)!).AvailableFreeSpace; }
|
||||||
|
catch (Exception) { return -1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新条目第一帧落盘时,在条目目录里放一个「待确认」标记。客户端把整个文件登记到主后端之后再来确认(Commit),标记才消失;
|
||||||
|
/// 一直没确认的(上传到一半放弃、浏览器关了)过了保留期由清理任务删掉,不会永远占着文件域的容量。
|
||||||
|
/// 在这个机制之前存下的条目没有标记,永远不会被清理。
|
||||||
|
/// </summary>
|
||||||
|
private const string PendingMarker = ".pending";
|
||||||
|
|
||||||
|
private string PathOf(string channelHex, string itemHex, uint index) => Path.Combine(_root, channelHex, itemHex, index.ToString("D10"));
|
||||||
|
|
||||||
|
/// <param name="quota">这个顶层目录(文件域)最多存多少字节,0 表示不限</param>
|
||||||
|
public async Task<PutResult> PutAsync(string channelHex, string itemHex, uint index, ReadOnlyMemory<byte> frame, byte[] digest, CancellationToken ct, long quota = 0)
|
||||||
|
{
|
||||||
|
string path = PathOf(channelHex, itemHex, index);
|
||||||
|
if (File.Exists(path)) return SameContent(path, digest) ? PutResult.Existing : PutResult.Conflict;
|
||||||
|
if (_maxBytes > 0 && UsedBytes + frame.Length > _maxBytes) return PutResult.Full;
|
||||||
|
if (quota > 0 && UsedBy(channelHex) + frame.Length > quota) return PutResult.OverQuota;
|
||||||
|
|
||||||
|
string itemDir = Path.GetDirectoryName(path)!;
|
||||||
|
bool fresh = !Directory.Exists(itemDir);
|
||||||
|
Directory.CreateDirectory(itemDir);
|
||||||
|
if (fresh) File.WriteAllBytes(Path.Combine(itemDir, PendingMarker), []);
|
||||||
|
string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||||
|
await using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous))
|
||||||
|
await fs.WriteAsync(frame, ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Move(tmp, path, overwrite: false);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException or UnauthorizedAccessException && File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(tmp); // 并发写入同一帧,对方先到了
|
||||||
|
return SameContent(path, digest) ? PutResult.Existing : PutResult.Conflict;
|
||||||
|
}
|
||||||
|
Interlocked.Add(ref _used, frame.Length);
|
||||||
|
Interlocked.Add(ref CounterOf(channelHex)[0], frame.Length);
|
||||||
|
return PutResult.Created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameContent(string path, byte[] digest)
|
||||||
|
{
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(SHA256.HashData(fs), digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>确认一个条目:已经登记到主后端,不再是待确认。条目不存在返回 false;没有标记(旧条目)也算成功</summary>
|
||||||
|
public bool Commit(string channelHex, string itemHex)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(_root, channelHex, itemHex);
|
||||||
|
if (!Directory.Exists(dir)) return false;
|
||||||
|
File.Delete(Path.Combine(dir, PendingMarker));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>一直待确认、最后一次写入早于 olderThan 的条目(频道/条目)</summary>
|
||||||
|
public IEnumerable<string> StalePending(TimeSpan olderThan)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow - olderThan;
|
||||||
|
foreach (var channelDir in Directory.EnumerateDirectories(_root))
|
||||||
|
{
|
||||||
|
foreach (var itemDir in Directory.EnumerateDirectories(channelDir))
|
||||||
|
{
|
||||||
|
if (!File.Exists(Path.Combine(itemDir, PendingMarker))) continue;
|
||||||
|
var newest = Directory.EnumerateFiles(itemDir).Select(File.GetLastWriteTimeUtc).DefaultIfEmpty(DateTime.MinValue).Max();
|
||||||
|
if (newest < cutoff) yield return $"{Path.GetFileName(channelDir)}/{Path.GetFileName(itemDir)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>自 from 起连续存在的帧(遇到缺口就停)。调用方负责按需读取,这里只给文件路径。</summary>
|
||||||
|
public IEnumerable<string> FramesFrom(string channelHex, string itemHex, uint from)
|
||||||
|
{
|
||||||
|
for (uint index = from; ; index++)
|
||||||
|
{
|
||||||
|
string path = PathOf(channelHex, itemHex, index);
|
||||||
|
if (!File.Exists(path)) yield break;
|
||||||
|
yield return path;
|
||||||
|
if (index == uint.MaxValue) yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>前缀是 {频道} 或 {频道}/{条目}</summary>
|
||||||
|
public bool DeletePrefix(string prefix)
|
||||||
|
{
|
||||||
|
var parts = prefix.Split('/');
|
||||||
|
if (parts.Length is < 1 or > 2 || parts.Any(p => !FileWire.IsHex32(p))) return false;
|
||||||
|
string dir = Path.Combine(_root, Path.Combine(parts));
|
||||||
|
if (!Directory.Exists(dir)) return true;
|
||||||
|
// 这个域的用量计数第一次用到时要扫磁盘:必须在删除之前扫,否则删掉的字节会被先排除、再被减一次
|
||||||
|
var counter = CounterOf(parts[0]);
|
||||||
|
long size = Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".tmp", StringComparison.Ordinal)).Sum(p => new FileInfo(p).Length);
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
Interlocked.Add(ref _used, -size);
|
||||||
|
if (parts.Length == 1) _usedBy.TryRemove(parts[0], out _); else Interlocked.Add(ref counter[0], -size);
|
||||||
|
ForgetSynced(prefix, parts);
|
||||||
|
if (parts.Length == 2) TryRemoveEmpty(Path.Combine(_root, parts[0]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string TombstoneLog => Path.Combine(_syncRoot, "deleted.log");
|
||||||
|
private readonly object _syncGate = new();
|
||||||
|
|
||||||
|
/// <summary>删除了的内容,如果已经同步到外部存储,记下删除时间,过了保留期再从外部存储清理</summary>
|
||||||
|
private void ForgetSynced(string prefix, string[] parts)
|
||||||
|
{
|
||||||
|
string markers = Path.Combine(_syncRoot, Path.Combine(parts));
|
||||||
|
if (!Directory.Exists(markers)) return;
|
||||||
|
lock (_syncGate)
|
||||||
|
{
|
||||||
|
File.AppendAllText(TombstoneLog, $"{prefix}\t{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}\n");
|
||||||
|
Directory.Delete(markers, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>还没同步到外部存储的帧:(外部存储里的相对路径, 本地文件路径)</summary>
|
||||||
|
public IEnumerable<(string Key, string Path)> Unsynced()
|
||||||
|
{
|
||||||
|
foreach (var channelDir in Directory.EnumerateDirectories(_root))
|
||||||
|
{
|
||||||
|
string channel = Path.GetFileName(channelDir);
|
||||||
|
foreach (var itemDir in Directory.EnumerateDirectories(channelDir))
|
||||||
|
{
|
||||||
|
string item = Path.GetFileName(itemDir);
|
||||||
|
if (File.Exists(Path.Combine(itemDir, PendingMarker))) continue; // 还没确认的不同步
|
||||||
|
foreach (var file in Directory.EnumerateFiles(itemDir).Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (file.EndsWith(".tmp", StringComparison.Ordinal) || Path.GetFileName(file).StartsWith('.')) continue;
|
||||||
|
string key = $"{channel}/{item}/{Path.GetFileName(file)}";
|
||||||
|
if (!File.Exists(Path.Combine(_syncRoot, channel, item, Path.GetFileName(file)))) yield return (key, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkSynced(string key)
|
||||||
|
{
|
||||||
|
string marker = Path.Combine(_syncRoot, key.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(marker)!);
|
||||||
|
File.WriteAllBytes(marker, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>保留期已过的删除记录。返回前缀;处理完调用 <see cref="RemoveTombstones"/></summary>
|
||||||
|
public IReadOnlyList<string> ExpiredTombstones(TimeSpan retain)
|
||||||
|
{
|
||||||
|
lock (_syncGate)
|
||||||
|
{
|
||||||
|
if (!File.Exists(TombstoneLog)) return [];
|
||||||
|
long cutoff = DateTimeOffset.UtcNow.Add(-retain).ToUnixTimeMilliseconds();
|
||||||
|
return File.ReadAllLines(TombstoneLog).Select(l => l.Split('\t')).Where(p => p.Length == 2 && long.TryParse(p[1], out var at) && at <= cutoff).Select(p => p[0]).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveTombstones(IEnumerable<string> prefixes)
|
||||||
|
{
|
||||||
|
var done = prefixes.ToHashSet();
|
||||||
|
lock (_syncGate)
|
||||||
|
{
|
||||||
|
if (!File.Exists(TombstoneLog)) return;
|
||||||
|
File.WriteAllLines(TombstoneLog, File.ReadAllLines(TombstoneLog).Where(l => !done.Contains(l.Split('\t')[0])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryRemoveEmpty(string dir)
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) Directory.Delete(dir); }
|
||||||
|
catch (IOException) { /* 并发写入时目录又有内容了 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>最后一次写入早于 olderThan 的条目(频道/条目)。对账用,正在上传的条目不会出现在里面</summary>
|
||||||
|
public IEnumerable<string> ItemsOlderThan(TimeSpan olderThan)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow - olderThan;
|
||||||
|
foreach (var channelDir in Directory.EnumerateDirectories(_root))
|
||||||
|
{
|
||||||
|
foreach (var itemDir in Directory.EnumerateDirectories(channelDir))
|
||||||
|
{
|
||||||
|
var newest = Directory.EnumerateFiles(itemDir).Select(File.GetLastWriteTimeUtc).DefaultIfEmpty(DateTime.MinValue).Max();
|
||||||
|
if (newest < cutoff) yield return $"{Path.GetFileName(channelDir)}/{Path.GetFileName(itemDir)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace WebDrop.FileApi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理一直没被确认的条目:字节传进来了,却没有登记到主后端(上传到一半放弃、关掉了浏览器)。
|
||||||
|
/// 它们会占着文件域的容量,过了保留期就删。已确认的条目、以及没有待确认标记的旧条目不受影响。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PendingSweeper(FileApiOptions options, FrameStore store, ILogger<PendingSweeper> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try { SweepOnce(); }
|
||||||
|
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { logger.LogWarning("清理待确认条目失败:{Type}", e.GetType().Name); }
|
||||||
|
}
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SweepOnce()
|
||||||
|
{
|
||||||
|
int removed = 0;
|
||||||
|
foreach (var prefix in store.StalePending(TimeSpan.FromHours(Math.Max(1, options.PendingRetentionHours))).ToList())
|
||||||
|
if (store.DeletePrefix(prefix)) removed++;
|
||||||
|
if (removed > 0) logger.LogInformation("清理了 {Count} 个一直没有确认的上传", removed);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
using WebDrop.FileApi;
|
||||||
|
|
||||||
|
var app = FileApiApp.Build(args);
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using WebDrop.FileProtocol;
|
||||||
|
using WebDrop.Sync;
|
||||||
|
|
||||||
|
namespace WebDrop.FileApi;
|
||||||
|
|
||||||
|
public sealed record SyncSummary(int Uploaded, long UploadedBytes, int Failed, int Purged);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每天定时把新增的文件帧同步到外部存储(S3 协议);删除了的内容在外部存储里再留 RetainDays 天才清理。
|
||||||
|
/// 帧的文件名里就是 频道/条目/序号,一帧一个远端文件,重复上传同样内容是幂等的,中途失败下次接着传。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SyncWorker(FileApiOptions options, FrameStore store, ILogger<SyncWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(30) };
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var backup = options.Backup;
|
||||||
|
if (!backup.Enabled || !backup.S3.Configured) return;
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var wait = DailySchedule.Next(DateTime.Now, backup.TimeOfDay) - DateTime.Now;
|
||||||
|
try { await Task.Delay(wait, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; }
|
||||||
|
try { await RunOnceAsync(stoppingToken).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||||
|
catch (Exception e) { logger.LogWarning("文件同步失败:{Type}", e.GetType().Name); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SyncSummary> RunOnceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var backup = options.Backup;
|
||||||
|
var s3 = new S3Client(_http, backup.S3);
|
||||||
|
var throttle = new Throttle(backup.MaxBytesPerSecond);
|
||||||
|
int uploaded = 0, failed = 0, purged = 0; long bytes = 0;
|
||||||
|
|
||||||
|
foreach (var (key, path) in store.Unsynced())
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var started = DateTime.UtcNow;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var file = File.OpenRead(path);
|
||||||
|
long length = file.Length; // 上传结束后流已经被释放
|
||||||
|
await s3.PutAsync($"files/{key}", file, ct).ConfigureAwait(false);
|
||||||
|
store.MarkSynced(key);
|
||||||
|
uploaded++; bytes += length;
|
||||||
|
await throttle.AfterAsync(length, DateTime.UtcNow - started, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is HttpRequestException or IOException)
|
||||||
|
{
|
||||||
|
// 外部存储连不上或这一帧读不了(正好被删):记一笔,连续失败几次就等下一次再来
|
||||||
|
if (++failed >= 5) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留期已过的删除记录:从外部存储清掉
|
||||||
|
var expired = store.ExpiredTombstones(TimeSpan.FromDays(Math.Max(0, backup.RetainDays)));
|
||||||
|
var cleaned = new List<string>();
|
||||||
|
foreach (var prefix in expired)
|
||||||
|
{
|
||||||
|
try { await s3.DeleteAsync($"files/{prefix}", ct).ConfigureAwait(false); cleaned.Add(prefix); purged++; }
|
||||||
|
catch (HttpRequestException) { break; }
|
||||||
|
}
|
||||||
|
store.RemoveTombstones(cleaned);
|
||||||
|
|
||||||
|
if (uploaded + purged + failed > 0) logger.LogInformation("文件同步:上传 {Uploaded} 帧({Bytes} 字节),失败 {Failed},清理 {Purged}", uploaded, bytes, failed, purged);
|
||||||
|
return new SyncSummary(uploaded, bytes, failed, purged);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace WebDrop.FileApi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记下用过的一次性票据(按票据编号),过期后随清理丢掉。放在内存里:重启后在票据有效期内理论上能重放一次,
|
||||||
|
/// 但票据有效期只有几分钟,而且下载次数的计数在主后端,代价可以接受。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UsedTickets
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, long> _used = new();
|
||||||
|
private long _lastPurge;
|
||||||
|
|
||||||
|
/// <summary>第一次用返回 true;同一个编号再用返回 false。编号为空的票据(旧格式)不被接受</summary>
|
||||||
|
public bool TryUse(string jti, long expiresMs)
|
||||||
|
{
|
||||||
|
if (jti.Length == 0) return false;
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
if (now - Interlocked.Read(ref _lastPurge) > 60_000)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _lastPurge, now);
|
||||||
|
foreach (var (key, expiry) in _used) if (expiry < now) _used.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
return _used.TryAdd(jti, expiresMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<!-- 文件服务:只保存文件帧的密文,按票据收发。部署在家宽,和主后端(WebDrop.Api)分开,只共用 WebDrop.FileProtocol -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<StripSymbols>true</StripSymbols>
|
||||||
|
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
<AssemblyName>webdrop-files</AssemblyName>
|
||||||
|
<RootNamespace>WebDrop.FileApi</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\WebDrop.FileProtocol\WebDrop.FileProtocol.csproj" />
|
||||||
|
<ProjectReference Include="..\WebDrop.Sync\WebDrop.Sync.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.AspNetCore.Hosting.Diagnostics": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FileApi": {
|
||||||
|
"DataDir": "files-data",
|
||||||
|
"Secret": "",
|
||||||
|
"CorsOrigins": [],
|
||||||
|
"MaxStoreBytes": 0,
|
||||||
|
"MaxFrameBytes": 1048642
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件域的选项,由文件服务(fileAPI)决定并对外公布:创建频道时能分配多大的文件空间。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FileDomainOptions(
|
||||||
|
[property: JsonPropertyName("quota_choices")] long[] QuotaChoices,
|
||||||
|
[property: JsonPropertyName("default_quota")] long DefaultQuota)
|
||||||
|
{
|
||||||
|
/// <summary>没有配置时的内置档位(频道默认 10 GiB)</summary>
|
||||||
|
public static readonly FileDomainOptions Fallback = new([1L << 30, 5L << 30, 10L << 30, 50L << 30, 100L << 30], 10L << 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>文件域:创建频道时分配的文件空间。由文件服务创建和管理,quota 是容量,used 是已用字节</summary>
|
||||||
|
public sealed record FileDomainBody(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("space")] string Space,
|
||||||
|
[property: JsonPropertyName("quota")] long Quota,
|
||||||
|
[property: JsonPropertyName("used")] long Used,
|
||||||
|
[property: JsonPropertyName("created_at")] long CreatedAt);
|
||||||
|
|
||||||
|
public sealed record FileDomainCreate([property: JsonPropertyName("quota")] long Quota = 0);
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(FileDomainOptions))]
|
||||||
|
[JsonSerializable(typeof(FileDomainBody))]
|
||||||
|
[JsonSerializable(typeof(FileDomainCreate))]
|
||||||
|
public sealed partial class FileProtocolJson : JsonSerializerContext;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
/// <summary>文件服务(fileAPI)的三种凭据用途</summary>
|
||||||
|
public enum FileOp : byte
|
||||||
|
{
|
||||||
|
/// <summary>上传某个条目的帧</summary>
|
||||||
|
Put = 1,
|
||||||
|
/// <summary>读取某个条目自某一帧起的内容</summary>
|
||||||
|
Get = 2,
|
||||||
|
/// <summary>主后端对文件服务的管理调用(删除、对账、复制)</summary>
|
||||||
|
Admin = 3,
|
||||||
|
/// <summary>在某个空间名下创建文件域(频道的文件空间)。由空间的所有者或管理员向主后端申请</summary>
|
||||||
|
Create = 4,
|
||||||
|
/// <summary>删除某个条目的所有帧。主后端确认调用者有权删除之后签发</summary>
|
||||||
|
Delete = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主后端签发、文件服务校验的短期票据:允许对哪个文件域(Channel)的哪个条目做什么,过期即失效。
|
||||||
|
/// Space 是签发这张票据的空间:文件服务只认域所属的空间签发的票据,别的空间即使知道域 id 也用不了。
|
||||||
|
/// Jti 是票据的唯一编号:读取和删除的票据只能用一次,重放无效(每次读取都向空间要新的,计次才有意义)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FileTicket(FileOp Op, string Channel, string Item, uint From, long ExpiresMs, string Space = "", string Jti = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 票据:ASCII 载荷 + HMAC-SHA256,双方共享同一个密钥。
|
||||||
|
/// 文件服务本来就保管密文,密钥泄露的后果也只限于文件服务这一侧(不涉及账号、频道密钥),所以不需要非对称签名。
|
||||||
|
/// </summary>
|
||||||
|
public static class FileTokens
|
||||||
|
{
|
||||||
|
public const int MinSecretBytes = 16;
|
||||||
|
|
||||||
|
public static string Sign(ReadOnlySpan<byte> secret, FileTicket ticket) =>
|
||||||
|
Seal(secret, $"t|{(byte)ticket.Op}|{ticket.Channel}|{ticket.Item}|{ticket.From}|{ticket.ExpiresMs}|{ticket.Space}|{(ticket.Jti.Length > 0 ? ticket.Jti : Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(12)))}");
|
||||||
|
|
||||||
|
public static bool TryVerify(ReadOnlySpan<byte> secret, string? token, long nowMs, out FileTicket ticket)
|
||||||
|
{
|
||||||
|
ticket = null!;
|
||||||
|
if (!Open(secret, token, out var parts) || parts.Length != 8 || parts[0] != "t") return false;
|
||||||
|
if (!byte.TryParse(parts[1], out var op) || !Enum.IsDefined((FileOp)op) || !uint.TryParse(parts[4], out var from)
|
||||||
|
|| !long.TryParse(parts[5], out var expires) || expires < nowMs) return false;
|
||||||
|
ticket = new FileTicket((FileOp)op, parts[2], parts[3], from, expires, parts[6], parts[7]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Seal(ReadOnlySpan<byte> secret, string payload)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.ASCII.GetBytes(payload);
|
||||||
|
var mac = HMACSHA256.HashData(secret, bytes);
|
||||||
|
return $"{Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')}.{Convert.ToBase64String(mac).TrimEnd('=').Replace('+', '-').Replace('/', '_')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Open(ReadOnlySpan<byte> secret, string? token, out string[] parts)
|
||||||
|
{
|
||||||
|
parts = [];
|
||||||
|
if (string.IsNullOrEmpty(token) || token.Length > 1024) return false;
|
||||||
|
int dot = token.IndexOf('.');
|
||||||
|
if (dot <= 0) return false;
|
||||||
|
if (!TryFromBase64Url(token[..dot], out var payload) || !TryFromBase64Url(token[(dot + 1)..], out var mac)) return false;
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(HMACSHA256.HashData(secret, payload), mac)) return false;
|
||||||
|
parts = Encoding.ASCII.GetString(payload).Split('|');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFromBase64Url(string text, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
var padded = text.Replace('-', '+').Replace('_', '/');
|
||||||
|
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
|
||||||
|
try { bytes = Convert.FromBase64String(padded); return true; }
|
||||||
|
catch (FormatException) { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
/// <summary>主后端、文件服务、浏览器三方约定的名字和格式</summary>
|
||||||
|
public static class FileWire
|
||||||
|
{
|
||||||
|
/// <summary>上传、读取时带的票据(主后端签发)</summary>
|
||||||
|
public const string TicketHeader = "X-WD-Ticket";
|
||||||
|
/// <summary>存储键:频道/条目/帧序号。帧序号补零到 10 位,按名字排序就是按帧序排序</summary>
|
||||||
|
public static string FrameKey(string channelHex, string itemHex, uint index) => $"{channelHex}/{itemHex}/{index:D10}";
|
||||||
|
|
||||||
|
public static bool IsHex32(string value)
|
||||||
|
{
|
||||||
|
if (value.Length != 32) return false;
|
||||||
|
foreach (char c in value) if (!(c is >= '0' and <= '9' or >= 'a' and <= 'f')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>帧封装的 26 字节明文头部(protocol.md §4),文件服务只看这几个字段,负载原样保存</summary>
|
||||||
|
public readonly record struct FrameHeader(byte Flags, string ItemHex, uint Index, int PayloadLength)
|
||||||
|
{
|
||||||
|
public const int Size = 26;
|
||||||
|
public const byte Version = 1;
|
||||||
|
public const byte FinalFlag = 0x01;
|
||||||
|
public int TotalLength => Size + PayloadLength;
|
||||||
|
|
||||||
|
public static bool TryParse(ReadOnlySpan<byte> data, out FrameHeader header)
|
||||||
|
{
|
||||||
|
header = default;
|
||||||
|
if (data.Length < Size || data[0] != Version || (data[1] & 0xE0) != 0) return false;
|
||||||
|
uint payload = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
|
||||||
|
if (payload > int.MaxValue) return false;
|
||||||
|
header = new FrameHeader(data[1], Convert.ToHexStringLower(data.Slice(2, 16)), BinaryPrimitives.ReadUInt32BigEndian(data[18..]), (int)payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- 主后端和文件服务(fileAPI)共用的协议:票据、回执、帧头部。两边分开部署,所以只放这一点点,不依赖任何业务代码 -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using WebDrop.Core;
|
||||||
|
|
||||||
|
namespace WebDrop.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 头像按内容哈希存成磁盘文件,数据库里只记文件名(引用,形如 3f…a1.webp)。
|
||||||
|
/// 下载时直接把文件交给服务器发送,不用把几 MB 的 base64 读进托管内存再解码。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AvatarFiles
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, string> MimeByExtension = new()
|
||||||
|
{
|
||||||
|
["png"] = "image/png", ["jpg"] = "image/jpeg", ["webp"] = "image/webp", ["gif"] = "image/gif",
|
||||||
|
};
|
||||||
|
private readonly string _dir;
|
||||||
|
|
||||||
|
public AvatarFiles(string dir)
|
||||||
|
{
|
||||||
|
_dir = Path.GetFullPath(dir);
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Dir => _dir;
|
||||||
|
|
||||||
|
/// <summary>旧数据把整段 data URL 存在数据库里;新数据是文件名</summary>
|
||||||
|
public static bool IsLegacy(string? value) => value is not null && value.StartsWith("data:", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>把已校验的 data URL 解码写成文件(同样内容只写一份),返回引用</summary>
|
||||||
|
public string Save(string dataUrl)
|
||||||
|
{
|
||||||
|
var chars = dataUrl.AsSpan();
|
||||||
|
int semicolon = chars.IndexOf(';'), comma = chars.IndexOf(',');
|
||||||
|
if (!dataUrl.StartsWith("data:image/", StringComparison.Ordinal) || semicolon < 0 || comma < semicolon) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
string extension = chars[11..semicolon].ToString() switch { "jpeg" => "jpg", var other => other };
|
||||||
|
if (!MimeByExtension.ContainsKey(extension)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
var payload = chars[(comma + 1)..];
|
||||||
|
var bytes = new byte[payload.Length / 4 * 3 + 3];
|
||||||
|
if (!Convert.TryFromBase64Chars(payload, bytes, out int written)) throw new WebDropException(ErrorCode.BadRequest);
|
||||||
|
string name = Convert.ToHexStringLower(SHA256.HashData(bytes.AsSpan(0, written))) + "." + extension;
|
||||||
|
string path = Path.Combine(_dir, name);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
string temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||||
|
File.WriteAllBytes(temp, bytes.AsSpan(0, written));
|
||||||
|
File.Move(temp, path, overwrite: true);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AvatarBlob? Resolve(string? reference)
|
||||||
|
{
|
||||||
|
if (reference is null || IsLegacy(reference)) return null;
|
||||||
|
int dot = reference.IndexOf('.');
|
||||||
|
if (dot != 64 || !MimeByExtension.TryGetValue(reference[(dot + 1)..], out var mime)) return null;
|
||||||
|
foreach (char c in reference.AsSpan(0, 64)) if (!char.IsAsciiHexDigitLower(c)) return null;
|
||||||
|
string path = Path.Combine(_dir, reference);
|
||||||
|
return File.Exists(path) ? new AvatarBlob(path, mime, reference[..64]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>启动时调用:删掉没有任何记录引用的文件(替换、删除头像后留下的)</summary>
|
||||||
|
public void Sweep(IReadOnlySet<string> referenced)
|
||||||
|
{
|
||||||
|
foreach (var path in System.IO.Directory.EnumerateFiles(_dir))
|
||||||
|
{
|
||||||
|
if (referenced.Contains(Path.GetFileName(path))) continue;
|
||||||
|
try { File.Delete(path); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
+9
@@ -4,6 +4,14 @@ namespace WebDrop.Storage;
|
|||||||
|
|
||||||
public sealed partial class SqliteAccountStore
|
public sealed partial class SqliteAccountStore
|
||||||
{
|
{
|
||||||
|
/// <summary>头像文件(full 为原图,否则缩略图,没有缩略图就用原图)。没有头像为 null</summary>
|
||||||
|
public AvatarBlob? ProfileAvatar(string username, bool full)
|
||||||
|
{
|
||||||
|
var profile = Profile(username);
|
||||||
|
return _avatars.Resolve(full ? profile.Avatar : profile.AvatarThumb ?? profile.Avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Avatar / AvatarThumb 是磁盘上的头像文件名,不是图片内容</summary>
|
||||||
public AccountProfile Profile(string username)
|
public AccountProfile Profile(string username)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -22,6 +30,7 @@ public sealed partial class SqliteAccountStore
|
|||||||
if (keepAvatar) { var current = Profile(username); avatar = current.Avatar; avatarThumb = current.AvatarThumb; }
|
if (keepAvatar) { var current = Profile(username); avatar = current.Avatar; avatarThumb = current.AvatarThumb; }
|
||||||
else if (avatar is not null) AvatarData.Validate(avatar, AvatarData.MaxAccountGifChars);
|
else if (avatar is not null) AvatarData.Validate(avatar, AvatarData.MaxAccountGifChars);
|
||||||
if (!keepAvatar && avatarThumb is not null) { if (avatar is null) throw new WebDropException(ErrorCode.BadRequest); AvatarData.ValidateThumb(avatarThumb); }
|
if (!keepAvatar && avatarThumb is not null) { if (avatar is null) throw new WebDropException(ErrorCode.BadRequest); AvatarData.ValidateThumb(avatarThumb); }
|
||||||
|
if (!keepAvatar) { avatar = avatar is null ? null : _avatars.Save(avatar); avatarThumb = avatarThumb is null ? null : _avatars.Save(avatarThumb); }
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
using var cmd = _connection.CreateCommand();
|
using var cmd = _connection.CreateCommand();
|
||||||
File renamed without changes.
+44
-2
@@ -8,10 +8,13 @@ public sealed partial class SqliteAccountStore : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
private readonly AvatarFiles _avatars;
|
||||||
|
|
||||||
public SqliteAccountStore(string path)
|
public SqliteAccountStore(string path, AvatarFiles? avatars = null)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
string directory = Path.GetDirectoryName(Path.GetFullPath(path))!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
_avatars = avatars ?? new AvatarFiles(Path.Combine(directory, "avatars", "account"));
|
||||||
_connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString());
|
_connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString());
|
||||||
_connection.Open();
|
_connection.Open();
|
||||||
using var cmd = _connection.CreateCommand();
|
using var cmd = _connection.CreateCommand();
|
||||||
@@ -52,6 +55,45 @@ public sealed partial class SqliteAccountStore : IDisposable
|
|||||||
alter.ExecuteNonQuery();
|
alter.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MigrateAvatars();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>旧版把头像的 data URL 整段存在数据库里:搬成磁盘文件(幂等,一个账号一个账号处理),再删掉没人引用的文件</summary>
|
||||||
|
private void MigrateAvatars()
|
||||||
|
{
|
||||||
|
var legacy = new List<string>();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT username FROM account_profiles WHERE avatar LIKE 'data:%' OR avatar_thumb LIKE 'data:%'";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read()) legacy.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
foreach (var username in legacy)
|
||||||
|
{
|
||||||
|
string? avatar, thumb;
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT avatar, avatar_thumb FROM account_profiles WHERE username=$u";
|
||||||
|
cmd.Parameters.AddWithValue("$u", username);
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
reader.Read();
|
||||||
|
avatar = reader.IsDBNull(0) ? null : reader.GetString(0); thumb = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||||
|
}
|
||||||
|
using var update = _connection.CreateCommand();
|
||||||
|
update.CommandText = "UPDATE account_profiles SET avatar=$a, avatar_thumb=$t WHERE username=$u";
|
||||||
|
update.Parameters.AddWithValue("$u", username);
|
||||||
|
update.Parameters.AddWithValue("$a", (object?)(AvatarFiles.IsLegacy(avatar) ? _avatars.Save(avatar!) : avatar) ?? DBNull.Value);
|
||||||
|
update.Parameters.AddWithValue("$t", (object?)(AvatarFiles.IsLegacy(thumb) ? _avatars.Save(thumb!) : thumb) ?? DBNull.Value);
|
||||||
|
update.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
var referenced = new HashSet<string>();
|
||||||
|
using (var cmd = _connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT avatar, avatar_thumb FROM account_profiles";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read()) { if (!reader.IsDBNull(0)) referenced.Add(reader.GetString(0)); if (!reader.IsDBNull(1)) referenced.Add(reader.GetString(1)); }
|
||||||
|
}
|
||||||
|
_avatars.Sweep(referenced);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
|
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
|
||||||
+64
-8
@@ -15,11 +15,13 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
private readonly string _connectionString;
|
private readonly string _connectionString;
|
||||||
private readonly SqliteConnection _writeConn;
|
private readonly SqliteConnection _writeConn;
|
||||||
private readonly object _writeLock = new();
|
private readonly object _writeLock = new();
|
||||||
|
private readonly AvatarFiles _avatars;
|
||||||
|
|
||||||
public SqliteChannelStore(string dbPath)
|
public SqliteChannelStore(string dbPath, AvatarFiles? avatars = null)
|
||||||
{
|
{
|
||||||
string? dir = Path.GetDirectoryName(Path.GetFullPath(dbPath));
|
string? dir = Path.GetDirectoryName(Path.GetFullPath(dbPath));
|
||||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
_avatars = avatars ?? new AvatarFiles(Path.Combine(dir ?? ".", "avatars", "space"));
|
||||||
|
|
||||||
_connectionString = new SqliteConnectionStringBuilder
|
_connectionString = new SqliteConnectionStringBuilder
|
||||||
{
|
{
|
||||||
@@ -49,6 +51,55 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
||||||
CREATE TABLE IF NOT EXISTS channel_avatars (space_id BLOB PRIMARY KEY, avatar TEXT NOT NULL, thumb TEXT) WITHOUT ROWID;
|
CREATE TABLE IF NOT EXISTS channel_avatars (space_id BLOB PRIMARY KEY, avatar TEXT NOT NULL, thumb TEXT) WITHOUT ROWID;
|
||||||
""");
|
""");
|
||||||
|
MigrateAvatars();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>旧版把头像的 data URL 整段存在数据库里:搬成磁盘文件(幂等,一行一行处理,不一次读入),再删掉没人引用的文件</summary>
|
||||||
|
private void MigrateAvatars()
|
||||||
|
{
|
||||||
|
var legacy = new List<byte[]>();
|
||||||
|
using (var cmd = _writeConn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT space_id FROM channel_avatars WHERE avatar LIKE 'data:%' OR thumb LIKE 'data:%'";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read()) legacy.Add((byte[])reader[0]);
|
||||||
|
}
|
||||||
|
foreach (var key in legacy)
|
||||||
|
{
|
||||||
|
string? avatar, thumb;
|
||||||
|
using (var cmd = _writeConn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT avatar, thumb FROM channel_avatars WHERE space_id=@c";
|
||||||
|
cmd.Parameters.AddWithValue("@c", key);
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
reader.Read();
|
||||||
|
avatar = reader.GetString(0); thumb = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||||
|
}
|
||||||
|
using var update = _writeConn.CreateCommand();
|
||||||
|
update.CommandText = "UPDATE channel_avatars SET avatar=@a, thumb=@t WHERE space_id=@c";
|
||||||
|
update.Parameters.AddWithValue("@c", key);
|
||||||
|
update.Parameters.AddWithValue("@a", AvatarFiles.IsLegacy(avatar) ? _avatars.Save(avatar) : avatar);
|
||||||
|
update.Parameters.AddWithValue("@t", (object?)(AvatarFiles.IsLegacy(thumb) ? _avatars.Save(thumb!) : thumb) ?? DBNull.Value);
|
||||||
|
update.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
var referenced = new HashSet<string>();
|
||||||
|
using (var cmd = _writeConn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT avatar, thumb FROM channel_avatars";
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read()) { referenced.Add(reader.GetString(0)); if (!reader.IsDBNull(1)) referenced.Add(reader.GetString(1)); }
|
||||||
|
}
|
||||||
|
_avatars.Sweep(referenced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把整个数据库(含账号表,它们在同一个文件里)做一份一致的快照,不阻塞正在服务的请求</summary>
|
||||||
|
public void Snapshot(string path)
|
||||||
|
{
|
||||||
|
using var conn = Open();
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "VACUUM INTO $path";
|
||||||
|
cmd.Parameters.AddWithValue("$path", path);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
public T Read<T>(Func<IResourceTx, T> action)
|
public T Read<T>(Func<IResourceTx, T> action)
|
||||||
@@ -66,7 +117,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
SqliteConnection.ClearPool(conn);
|
SqliteConnection.ClearPool(conn);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
using (tx) return action(new SqliteTx(conn));
|
using (tx) return action(new SqliteTx(conn, _avatars));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +128,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
using var tx = _writeConn.BeginTransaction();
|
using var tx = _writeConn.BeginTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
T result = action(new SqliteTx(_writeConn));
|
T result = action(new SqliteTx(_writeConn, _avatars));
|
||||||
tx.Commit();
|
tx.Commit();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -255,7 +306,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
|
CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
|
private sealed class SqliteTx(SqliteConnection conn, AvatarFiles avatars) : IResourceTx
|
||||||
{
|
{
|
||||||
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
|
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
|
||||||
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
|
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
|
||||||
@@ -509,13 +560,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|||||||
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
|
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? GetChannelAvatar(Id16 id, bool thumb) =>
|
public AvatarBlob? GetChannelAvatar(Id16 id, bool thumb) => avatars.Resolve(
|
||||||
Scalar(thumb ? "SELECT COALESCE(thumb, avatar) FROM channel_avatars WHERE space_id=@c" : "SELECT avatar FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string;
|
Scalar(thumb ? "SELECT COALESCE(thumb, avatar) FROM channel_avatars WHERE space_id=@c" : "SELECT avatar FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string);
|
||||||
|
|
||||||
|
/// <summary>头像文件名(内容哈希)就是版本:内容变了名字才会变</summary>
|
||||||
|
public string? GetChannelAvatarVersion(Id16 id) =>
|
||||||
|
Scalar("SELECT avatar || '|' || COALESCE(thumb, '') FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string;
|
||||||
|
|
||||||
public void SetChannelAvatar(Id16 id, string? avatar, string? thumb)
|
public void SetChannelAvatar(Id16 id, string? avatar, string? thumb)
|
||||||
{
|
{
|
||||||
if (avatar is null) Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray()));
|
if (avatar is null) { Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())); return; }
|
||||||
else Run("INSERT INTO channel_avatars (space_id, avatar, thumb) VALUES (@c,@a,@t) ON CONFLICT(space_id) DO UPDATE SET avatar=excluded.avatar, thumb=excluded.thumb", ("@c", id.ToArray()), ("@a", avatar), ("@t", (object?)thumb ?? DBNull.Value));
|
Run("INSERT INTO channel_avatars (space_id, avatar, thumb) VALUES (@c,@a,@t) ON CONFLICT(space_id) DO UPDATE SET avatar=excluded.avatar, thumb=excluded.thumb",
|
||||||
|
("@c", id.ToArray()), ("@a", avatars.Save(avatar)), ("@t", (object?)(thumb is null ? null : avatars.Save(thumb)) ?? DBNull.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
|
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
|
||||||
File renamed without changes.
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace WebDrop.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 只实现备份用得到的 S3 操作:上传、删除、按前缀列出。自己做 SigV4 签名,不引 AWS SDK(NativeAOT 友好)。
|
||||||
|
/// 上传的流必须可以回退:先扫一遍算摘要再发送,这样不用把大文件读进内存。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class S3Client(HttpClient http, S3Options options)
|
||||||
|
{
|
||||||
|
private static readonly XNamespace Ns = "http://s3.amazonaws.com/doc/2006-03-01/";
|
||||||
|
private static readonly string EmptyHash = Convert.ToHexStringLower(SHA256.HashData([]));
|
||||||
|
private readonly string _base = options.BasePath.Trim('/');
|
||||||
|
|
||||||
|
private string KeyOf(string path) => string.Join('/', new[] { _base, path.Trim('/') }.Where(s => s.Length > 0));
|
||||||
|
|
||||||
|
public async Task PutAsync(string path, Stream content, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string hash = Convert.ToHexStringLower(await SHA256.HashDataAsync(content, ct).ConfigureAwait(false));
|
||||||
|
content.Position = 0;
|
||||||
|
using var request = Signed(HttpMethod.Put, KeyOf(path), [], hash);
|
||||||
|
request.Content = new StreamContent(content);
|
||||||
|
request.Content.Headers.ContentType = new("application/octet-stream");
|
||||||
|
using var response = await http.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"S3 PUT 失败:{(int)response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>删除 path 这个对象,以及它作为目录时底下的所有对象。什么都没删到返回 false</summary>
|
||||||
|
public async Task<bool> DeleteAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
bool any = false;
|
||||||
|
string key = KeyOf(path);
|
||||||
|
foreach (var name in await ListKeysAsync(key + "/", null, ct).ConfigureAwait(false)) any |= await DeleteKeyAsync(name, ct).ConfigureAwait(false);
|
||||||
|
return await DeleteKeyAsync(key, ct).ConfigureAwait(false) | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> DeleteKeyAsync(string key, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// S3 删除不存在的对象也回 204,所以先 HEAD 才知道有没有
|
||||||
|
using (var head = Signed(HttpMethod.Head, key, [], EmptyHash))
|
||||||
|
using (var existing = await http.SendAsync(head, ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (existing.StatusCode == HttpStatusCode.NotFound) return false;
|
||||||
|
if (!existing.IsSuccessStatusCode) throw new HttpRequestException($"S3 HEAD 失败:{(int)existing.StatusCode}");
|
||||||
|
}
|
||||||
|
using var request = Signed(HttpMethod.Delete, key, [], EmptyHash);
|
||||||
|
using var response = await http.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.NotFound) throw new HttpRequestException($"S3 DELETE 失败:{(int)response.StatusCode}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>列出目录下一层的名字(文件和子目录),不含目录自己</summary>
|
||||||
|
public async Task<IReadOnlyList<string>> ListAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string prefix = KeyOf(path) + "/";
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var key in await ListKeysAsync(prefix, "/", ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
string rest = key[prefix.Length..].TrimEnd('/');
|
||||||
|
if (rest.Length > 0) names.Add(rest);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<string>> ListKeysAsync(string prefix, string? delimiter, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
string? token = null;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var query = new List<KeyValuePair<string, string>> { new("list-type", "2"), new("prefix", prefix) };
|
||||||
|
if (delimiter is not null) query.Add(new("delimiter", delimiter));
|
||||||
|
if (token is not null) query.Add(new("continuation-token", token));
|
||||||
|
using var request = Signed(HttpMethod.Get, "", query, EmptyHash);
|
||||||
|
using var response = await http.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode) throw new HttpRequestException($"S3 LIST 失败:{(int)response.StatusCode}");
|
||||||
|
var root = XDocument.Parse(await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)).Root!;
|
||||||
|
result.AddRange(root.Elements(Ns + "Contents").Select(e => e.Element(Ns + "Key")!.Value));
|
||||||
|
result.AddRange(root.Elements(Ns + "CommonPrefixes").Select(e => e.Element(Ns + "Prefix")!.Value));
|
||||||
|
token = root.Element(Ns + "IsTruncated")?.Value == "true" ? root.Element(Ns + "NextContinuationToken")?.Value : null;
|
||||||
|
} while (token is not null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── SigV4
|
||||||
|
|
||||||
|
private HttpRequestMessage Signed(HttpMethod method, string key, IReadOnlyList<KeyValuePair<string, string>> query, string payloadHash)
|
||||||
|
{
|
||||||
|
var endpoint = new Uri(options.Endpoint.TrimEnd('/') + "/");
|
||||||
|
string host = endpoint.IsDefaultPort ? endpoint.Host : $"{endpoint.Host}:{endpoint.Port}";
|
||||||
|
if (!options.PathStyle) host = options.Bucket + "." + host;
|
||||||
|
string objectPath = key.Length == 0 ? "" : "/" + string.Join('/', key.Split('/').Select(Encode));
|
||||||
|
string canonicalUri = endpoint.AbsolutePath.TrimEnd('/') + (options.PathStyle ? "/" + Encode(options.Bucket) : "") + objectPath;
|
||||||
|
if (canonicalUri.Length == 0) canonicalUri = "/";
|
||||||
|
string canonicalQuery = string.Join('&', query.Select(p => (Key: Encode(p.Key), Value: Encode(p.Value)))
|
||||||
|
.OrderBy(p => p.Key, StringComparer.Ordinal).ThenBy(p => p.Value, StringComparer.Ordinal).Select(p => $"{p.Key}={p.Value}"));
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
string amzDate = now.ToString("yyyyMMdd'T'HHmmss'Z'"), day = now.ToString("yyyyMMdd");
|
||||||
|
const string signedHeaders = "host;x-amz-content-sha256;x-amz-date";
|
||||||
|
string canonicalHeaders = $"host:{host}\nx-amz-content-sha256:{payloadHash}\nx-amz-date:{amzDate}\n";
|
||||||
|
string canonical = $"{method.Method}\n{canonicalUri}\n{canonicalQuery}\n{canonicalHeaders}\n{signedHeaders}\n{payloadHash}";
|
||||||
|
string scope = $"{day}/{options.Region}/s3/aws4_request";
|
||||||
|
string toSign = $"AWS4-HMAC-SHA256\n{amzDate}\n{scope}\n{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))}";
|
||||||
|
byte[] signingKey = Hmac(Hmac(Hmac(Hmac(Encoding.UTF8.GetBytes("AWS4" + options.SecretKey), day), options.Region), "s3"), "aws4_request");
|
||||||
|
string signature = Convert.ToHexStringLower(Hmac(signingKey, toSign));
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(method, new Uri($"{endpoint.Scheme}://{host}{canonicalUri}" + (canonicalQuery.Length > 0 ? "?" + canonicalQuery : "")));
|
||||||
|
request.Headers.TryAddWithoutValidation("x-amz-content-sha256", payloadHash);
|
||||||
|
request.Headers.TryAddWithoutValidation("x-amz-date", amzDate);
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", $"AWS4-HMAC-SHA256 Credential={options.AccessKey}/{scope}, SignedHeaders={signedHeaders}, Signature={signature}");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Hmac(byte[] key, string data) => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
|
||||||
|
|
||||||
|
/// <summary>RFC 3986:只有字母数字和 -_.~ 不转义</summary>
|
||||||
|
private static string Encode(string value)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (byte b in Encoding.UTF8.GetBytes(value))
|
||||||
|
{
|
||||||
|
if (b is >= (byte)'A' and <= (byte)'Z' or >= (byte)'a' and <= (byte)'z' or >= (byte)'0' and <= (byte)'9' or (byte)'-' or (byte)'_' or (byte)'.' or (byte)'~') sb.Append((char)b);
|
||||||
|
else sb.Append('%').Append(b.ToString("X2"));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace WebDrop.Sync;
|
||||||
|
|
||||||
|
/// <summary>外部存储:任何兼容 S3 协议的服务(AWS S3、Cloudflare R2、MinIO、Backblaze B2、阿里云 OSS ……)</summary>
|
||||||
|
public sealed class S3Options
|
||||||
|
{
|
||||||
|
/// <summary>服务地址,如 https://s3.us-east-1.amazonaws.com 或 http://minio:9000</summary>
|
||||||
|
public string Endpoint { get; set; } = "";
|
||||||
|
/// <summary>签名用的区域;MinIO 等可以保持 us-east-1(R2 用 auto)</summary>
|
||||||
|
public string Region { get; set; } = "us-east-1";
|
||||||
|
public string Bucket { get; set; } = "";
|
||||||
|
public string AccessKey { get; set; } = "";
|
||||||
|
public string SecretKey { get; set; } = "";
|
||||||
|
/// <summary>对象键的统一前缀,如 webdrop</summary>
|
||||||
|
public string BasePath { get; set; } = "webdrop";
|
||||||
|
/// <summary>路径风格(endpoint/bucket/key),MinIO 等需要;false 为虚拟主机风格(bucket.endpoint/key)</summary>
|
||||||
|
public bool PathStyle { get; set; } = true;
|
||||||
|
|
||||||
|
public bool Configured => Endpoint.Length > 0 && Bucket.Length > 0 && AccessKey.Length > 0 && SecretKey.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>每天固定时刻运行一次的备份计划。时间用服务器本地时区,形如 04:00</summary>
|
||||||
|
public sealed class DailyBackupOptions
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public string Time { get; set; } = "04:00";
|
||||||
|
/// <summary>外部备份保留多少天;被删除的内容在备份里最多再留这么久</summary>
|
||||||
|
public int RetainDays { get; set; } = 30;
|
||||||
|
/// <summary>限速(字节/秒),0 表示不限。家宽上行只有 50M,可以限一下,别把聊天上传挤掉</summary>
|
||||||
|
public long MaxBytesPerSecond { get; set; }
|
||||||
|
public S3Options S3 { get; set; } = new();
|
||||||
|
|
||||||
|
public TimeOnly TimeOfDay => TimeOnly.TryParse(Time, out var t) ? t : new TimeOnly(4, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DailySchedule
|
||||||
|
{
|
||||||
|
/// <summary>下一次运行的时刻(严格晚于 now)</summary>
|
||||||
|
public static DateTime Next(DateTime now, TimeOnly at)
|
||||||
|
{
|
||||||
|
var today = now.Date + at.ToTimeSpan();
|
||||||
|
return today > now ? today : today.AddDays(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace WebDrop.Sync;
|
||||||
|
|
||||||
|
/// <summary>粗略的限速:每发完一块,按它占的时间睡够。0 表示不限</summary>
|
||||||
|
public sealed class Throttle(long bytesPerSecond)
|
||||||
|
{
|
||||||
|
public async Task AfterAsync(long bytes, TimeSpan took, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (bytesPerSecond <= 0 || bytes <= 0) return;
|
||||||
|
var target = TimeSpan.FromSeconds((double)bytes / bytesPerSecond);
|
||||||
|
if (target > took) await Task.Delay(target - took, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- 同步到外部存储(OpenList 等,走 S3 协议)。主后端和文件服务各自用它备份自己的数据,所以单独成项目,不依赖任何业务代码 -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using static WebDrop.Api.Tests.ServerFixture;
|
||||||
|
|
||||||
|
namespace WebDrop.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>主后端每天把数据库快照压缩后传到外部存储(S3 协议),按保留期清理旧的。</summary>
|
||||||
|
public sealed class BackupTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly FakeS3 _s3 = new();
|
||||||
|
private ServerFixture _server = null!;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _s3.StartAsync();
|
||||||
|
_server = ServerFixture.WithSettings(new()
|
||||||
|
{
|
||||||
|
["WebDrop:Backup:S3:Endpoint"] = _s3.Url,
|
||||||
|
["WebDrop:Backup:S3:AccessKey"] = FakeS3.AccessKey,
|
||||||
|
["WebDrop:Backup:S3:SecretKey"] = FakeS3.SecretKey,
|
||||||
|
["WebDrop:Backup:S3:BasePath"] = "webdrop",
|
||||||
|
["WebDrop:Backup:S3:Bucket"] = FakeS3.Bucket,
|
||||||
|
["WebDrop:Backup:RetainDays"] = "30",
|
||||||
|
});
|
||||||
|
await _server.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _server.DisposeAsync();
|
||||||
|
await _s3.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheSnapshotIsAConsistentCompressedDatabaseAndOldOnesAreRetired()
|
||||||
|
{
|
||||||
|
var ch = await _server.CreateAsync();
|
||||||
|
var worker = _server.App.Services.GetServices<Microsoft.Extensions.Hosting.IHostedService>().OfType<WebDrop.Api.BackupWorker>().Single();
|
||||||
|
|
||||||
|
// 40 天前做的一份,和今天的一份
|
||||||
|
var old = await worker.RunOnceAsync(DateTime.Now.AddDays(-40), CancellationToken.None);
|
||||||
|
Assert.Equal(0, old.Purged);
|
||||||
|
Assert.True(_s3.Files.ContainsKey($"webdrop/chat/{old.Name}"));
|
||||||
|
|
||||||
|
var today = await worker.RunOnceAsync(DateTime.Now, CancellationToken.None);
|
||||||
|
Assert.Equal(1, today.Purged); // 超过 30 天的那份被清掉
|
||||||
|
Assert.False(_s3.Files.ContainsKey($"webdrop/chat/{old.Name}"));
|
||||||
|
Assert.True(_s3.Files.ContainsKey($"webdrop/chat/{today.Name}"));
|
||||||
|
|
||||||
|
// 备份能还原成数据库,里面有刚建的频道和账号
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), "restore-" + Guid.NewGuid().ToString("N") + ".db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var input = new MemoryStream(_s3.Files[$"webdrop/chat/{today.Name}"]))
|
||||||
|
using (var zip = new GZipStream(input, CompressionMode.Decompress))
|
||||||
|
using (var output = File.Create(path))
|
||||||
|
zip.CopyTo(output);
|
||||||
|
using var conn = new SqliteConnection($"Data Source={path};Mode=ReadOnly");
|
||||||
|
conn.Open();
|
||||||
|
using var count = conn.CreateCommand();
|
||||||
|
count.CommandText = "SELECT (SELECT COUNT(*) FROM channels), (SELECT COUNT(*) FROM accounts)";
|
||||||
|
using var reader = count.ExecuteReader();
|
||||||
|
Assert.True(reader.Read());
|
||||||
|
Assert.True(reader.GetInt64(0) >= 1 && reader.GetInt64(1) >= 2);
|
||||||
|
}
|
||||||
|
finally { SqliteConnection.ClearAllPools(); try { File.Delete(path); } catch (IOException) { } }
|
||||||
|
|
||||||
|
// 本地不留临时文件
|
||||||
|
Assert.Empty(Directory.EnumerateFiles(Path.Combine(_server.DataDir, "v2", "backup-tmp")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace WebDrop.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 只实现备份用到的 S3 子集(PUT、HEAD、DELETE、ListObjectsV2),路径风格,数据放在内存里。
|
||||||
|
/// 检查 SigV4 的凭据字段和 x-amz-content-sha256 与正文是否一致;不重算签名本身(签名算法由真实服务兜底)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeS3 : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _app;
|
||||||
|
/// <summary>键(不含存储桶)→ 内容</summary>
|
||||||
|
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
|
||||||
|
public bool Down { get; set; }
|
||||||
|
public string Url { get; private set; } = "";
|
||||||
|
public const string AccessKey = "AKIATEST", SecretKey = "secret", Bucket = "bk";
|
||||||
|
|
||||||
|
public FakeS3()
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateSlimBuilder();
|
||||||
|
builder.Configuration["urls"] = "http://127.0.0.1:0";
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
_app = builder.Build();
|
||||||
|
_app.Map("/{bucket}/{**key}", async (HttpContext ctx, string bucket, string? key) =>
|
||||||
|
{
|
||||||
|
if (Down) { ctx.Response.StatusCode = 503; return; }
|
||||||
|
string auth = ctx.Request.Headers.Authorization.ToString();
|
||||||
|
if (bucket != Bucket || !auth.StartsWith($"AWS4-HMAC-SHA256 Credential={AccessKey}/", StringComparison.Ordinal) || !auth.Contains("Signature=") || !ctx.Request.Headers.ContainsKey("x-amz-date"))
|
||||||
|
{ ctx.Response.StatusCode = 403; return; }
|
||||||
|
key = Uri.UnescapeDataString(key ?? "");
|
||||||
|
switch (ctx.Request.Method)
|
||||||
|
{
|
||||||
|
case "PUT":
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await ctx.Request.Body.CopyToAsync(ms);
|
||||||
|
var body = ms.ToArray();
|
||||||
|
if (!string.Equals(ctx.Request.Headers["x-amz-content-sha256"], Convert.ToHexStringLower(SHA256.HashData(body)), StringComparison.Ordinal)) { ctx.Response.StatusCode = 400; return; }
|
||||||
|
Files[key] = body;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "HEAD": ctx.Response.StatusCode = Files.ContainsKey(key) ? 200 : 404; break;
|
||||||
|
case "DELETE": Files.TryRemove(key, out _); ctx.Response.StatusCode = 204; break;
|
||||||
|
case "GET":
|
||||||
|
{
|
||||||
|
string prefix = ctx.Request.Query["prefix"].ToString(), delimiter = ctx.Request.Query["delimiter"].ToString();
|
||||||
|
var keys = Files.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList();
|
||||||
|
XNamespace ns = "http://s3.amazonaws.com/doc/2006-03-01/";
|
||||||
|
var root = new XElement(ns + "ListBucketResult", new XElement(ns + "IsTruncated", "false"));
|
||||||
|
var folders = new SortedSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var k in keys)
|
||||||
|
{
|
||||||
|
int cut = delimiter.Length == 0 ? -1 : k.IndexOf(delimiter, prefix.Length, StringComparison.Ordinal);
|
||||||
|
if (cut < 0) root.Add(new XElement(ns + "Contents", new XElement(ns + "Key", k)));
|
||||||
|
else folders.Add(k[..(cut + delimiter.Length)]);
|
||||||
|
}
|
||||||
|
foreach (var f in folders) root.Add(new XElement(ns + "CommonPrefixes", new XElement(ns + "Prefix", f)));
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
ctx.Response.ContentType = "application/xml";
|
||||||
|
await ctx.Response.WriteAsync(root.ToString(), Encoding.UTF8);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: ctx.Response.StatusCode = 405; break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
await _app.StartAsync();
|
||||||
|
Url = _app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First().TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _app.StopAsync();
|
||||||
|
await _app.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using WebDrop.Core;
|
||||||
|
using WebDrop.FileApi;
|
||||||
|
using WebDrop.Storage;
|
||||||
|
using static WebDrop.Api.Tests.ServerFixture;
|
||||||
|
|
||||||
|
namespace WebDrop.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>主后端 + 文件服务两个独立的服务一起跑:文件内容只在文件服务上;上传、登记、下载、计次、删除的完整流程。</summary>
|
||||||
|
public sealed class FileApiTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string Secret = "files-test-secret-0123456789abcdef-xyz";
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-files-" + Guid.NewGuid().ToString("N"));
|
||||||
|
private WebApplication _files = null!;
|
||||||
|
private ServerFixture _main = null!;
|
||||||
|
private HttpClient _fileHttp = null!;
|
||||||
|
private string _fileUrl = "";
|
||||||
|
private Signer _owner = null!;
|
||||||
|
|
||||||
|
private sealed record Signer(string Token, string Username, string DeviceId, byte[] PrivateKey);
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_files = FileApiApp.Build([], b =>
|
||||||
|
{
|
||||||
|
b.Configuration["FileApi:DataDir"] = _dir;
|
||||||
|
b.Configuration["FileApi:Secret"] = Secret;
|
||||||
|
b.Configuration["urls"] = "http://127.0.0.1:0";
|
||||||
|
b.Logging.ClearProviders();
|
||||||
|
});
|
||||||
|
await _files.StartAsync();
|
||||||
|
_fileUrl = _files.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First().TrimEnd('/');
|
||||||
|
_fileHttp = new HttpClient { BaseAddress = new Uri(_fileUrl) };
|
||||||
|
|
||||||
|
_main = ServerFixture.WithSettings(new()
|
||||||
|
{
|
||||||
|
["WebDrop:Files:Enabled"] = "true",
|
||||||
|
["WebDrop:Files:PublicUrl"] = _fileUrl,
|
||||||
|
["WebDrop:Files:Secret"] = Secret,
|
||||||
|
});
|
||||||
|
await _main.InitializeAsync();
|
||||||
|
_owner = await RegisterSignerAsync("files_owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _main.DisposeAsync();
|
||||||
|
_fileHttp.Dispose();
|
||||||
|
await _files.StopAsync();
|
||||||
|
await _files.DisposeAsync();
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── 账号与签名:带设备签名密钥的账号,文件和聊天对象的最后一帧必须带签名事件 ─────
|
||||||
|
|
||||||
|
private async Task<Signer> RegisterSignerAsync(string username)
|
||||||
|
{
|
||||||
|
var accounts = _main.App.Services.GetRequiredService<SqliteAccountStore>();
|
||||||
|
var verifier = RandomNumberGenerator.GetBytes(32);
|
||||||
|
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
static string Jwk(ECPoint p) => JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(p.X!), y = B64(p.Y!) });
|
||||||
|
var devicePoint = device.ExportParameters(false).Q;
|
||||||
|
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], Jwk(invitation.ExportParameters(false).Q), Jwk(root.ExportParameters(false).Q)));
|
||||||
|
string deviceId = Guid.NewGuid().ToString("D");
|
||||||
|
var token = accounts.Authenticate(username, verifier, deviceId, "Test device")!;
|
||||||
|
var cert = root.SignData(SignatureCodec.BuildMessage("device.certificate", SignatureCodec.DeviceCertificatePayload(username, deviceId, devicePoint.X!, devicePoint.Y!)),
|
||||||
|
HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
|
||||||
|
{ Content = JsonBody(new { device_id = deviceId, public_key = Jwk(devicePoint), signature = Convert.ToBase64String(cert) }) };
|
||||||
|
request.Headers.Add("X-WD-Account", token);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(request)).StatusCode);
|
||||||
|
return new Signer(token, username, deviceId, device.ExportPkcs8PrivateKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>条目最后一帧的签名事件头:对所有帧的摘要(清单)签名</summary>
|
||||||
|
private Dictionary<string, string> SignedHeaders(string spaceId, string channelId, string itemId, CryptoDomain domain, string kind, IReadOnlyList<byte[]> frames)
|
||||||
|
{
|
||||||
|
Assert.True(Codec.TryDecodeExact(spaceId, 16, out var space)); Assert.True(Codec.TryDecodeExact(itemId, 16, out var item));
|
||||||
|
var manifest = SignatureCodec.FrameManifestHash(frames.Select(frame => SHA256.HashData(frame)).ToList());
|
||||||
|
var eventId = RandomNumberGenerator.GetBytes(16);
|
||||||
|
var payload = SignatureCodec.ContentEventPayload(space, channelId, _owner.Username, _owner.DeviceId, eventId, kind, domain, 1, item, frames.Count, manifest, []);
|
||||||
|
using var device = ECDsa.Create(); device.ImportPkcs8PrivateKey(_owner.PrivateKey, out _);
|
||||||
|
var signature = device.SignData(SignatureCodec.BuildMessage("content.event", payload), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||||
|
return new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["X-WD-Event-Id"] = B64(eventId), ["X-WD-Event-Kind"] = kind, ["X-WD-Event-Channel"] = channelId,
|
||||||
|
["X-WD-Event-Domain"] = domain == CryptoDomain.Chat ? "chat" : "files", ["X-WD-Event-Epoch"] = "1", ["X-WD-Event-Frames"] = frames.Count.ToString(),
|
||||||
|
["X-WD-Event-Manifest"] = B64(manifest), ["X-WD-Event-Signature"] = B64(signature),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Who Owner(Chan ch) => ch.Owner with { AccountToken = _owner.Token };
|
||||||
|
|
||||||
|
private async Task<Chan> CreateSpaceAsync()
|
||||||
|
{
|
||||||
|
var ch = new Chan(NewId(), ChannelMode.Enc);
|
||||||
|
var body = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = ch.Id, ["mode"] = "enc", ["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
|
||||||
|
["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)), ["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)),
|
||||||
|
["settings"] = new { name = "Files space", max_members = 2, join_policy = "open" },
|
||||||
|
};
|
||||||
|
using var response = await _main.Http.SendAsync(_main.Req(HttpMethod.Post, "/api/v1/channels", Owner(ch), JsonBody(body)));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),并为它创建一个文件域(同样是客户端做的事),之后才能声明对象</summary>
|
||||||
|
private async Task<string> PrepareAsync(Chan ch)
|
||||||
|
{
|
||||||
|
string channelId;
|
||||||
|
using (var request = _main.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", Owner(ch)))
|
||||||
|
using (var response = await _main.Http.SendAsync(request))
|
||||||
|
channelId = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
|
||||||
|
foreach (var domain in new[] { "chat", "files" })
|
||||||
|
{
|
||||||
|
using var bootstrap = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/crypto/domains", Owner(ch),
|
||||||
|
JsonBody(new { channelId, domain, epoch = 1, envelopes = new[] { new { channelId, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } }));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode);
|
||||||
|
}
|
||||||
|
await CreateDomainAsync(ch, channelId);
|
||||||
|
return channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeclareAsync(Chan ch, string channelId, string itemId, string domain = "files")
|
||||||
|
{
|
||||||
|
using var declare = _main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/crypto/objects", Owner(ch),
|
||||||
|
JsonBody(new { itemId, channelId, domain, epoch = 1, keyEnvelope = "opaque-object-key" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, (await _main.Http.SendAsync(declare)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── 主后端发票据,文件服务存字节 ─────
|
||||||
|
|
||||||
|
private readonly Dictionary<string, string> _domains = [];
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> GrantRequestAsync(Chan ch, string op, string? item = null, uint from = 0) =>
|
||||||
|
await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", Owner(ch),
|
||||||
|
new StringContent(JsonSerializer.Serialize(new { op, item, from }), Encoding.UTF8, "application/json")));
|
||||||
|
|
||||||
|
private async Task<(string Url, string Domain, string Ticket)> GrantAsync(Chan ch, string op, string? item = null, uint from = 0)
|
||||||
|
{
|
||||||
|
using var response = await GrantRequestAsync(ch, op, item, from);
|
||||||
|
if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException($"grant {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
var root = doc.RootElement;
|
||||||
|
return (root.GetProperty("url").GetString()!, root.GetProperty("domain").GetString()!, root.GetProperty("ticket").GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>前端创建频道时做的事:向空间要「创建」票据,去文件服务建文件域,再把域 id 记到频道上</summary>
|
||||||
|
private async Task<string> CreateDomainAsync(Chan ch, string channelId)
|
||||||
|
{
|
||||||
|
var grant = await GrantAsync(ch, "create");
|
||||||
|
using var create = new HttpRequestMessage(HttpMethod.Post, "/file/v1/domains") { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||||
|
create.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||||
|
using var created = await _fileHttp.SendAsync(create);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await created.Content.ReadAsStringAsync());
|
||||||
|
string domain = doc.RootElement.GetProperty("id").GetString()!;
|
||||||
|
using var record = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/channels/{channelId}/file-domain", Owner(ch), JsonBody(new { fileDomain = domain }));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await _main.Http.SendAsync(record)).StatusCode);
|
||||||
|
return _domains[ch.Id] = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把一帧存进文件域,返回文件服务算的摘要</summary>
|
||||||
|
private async Task<string> StoreAsync(Chan ch, string item, byte[] frame, uint index)
|
||||||
|
{
|
||||||
|
var grant = await GrantAsync(ch, "put", item);
|
||||||
|
using var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/{index}") { Content = new ByteArrayContent(frame) };
|
||||||
|
put.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||||
|
using var stored = await _fileHttp.SendAsync(put);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await stored.Content.ReadAsStringAsync());
|
||||||
|
string digest = doc.RootElement.GetProperty("digest").GetString()!;
|
||||||
|
Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(frame)), digest);
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>到主后端一次登记整个文件:末帧的 26 字节头部 + 每一帧的(负载长度,整帧摘要),不带任何字节</summary>
|
||||||
|
private async Task<HttpResponseMessage> RegisterAsync(Chan ch, IReadOnlyList<byte[]> frames, string? maxDownloads = null, Dictionary<string, string>? signed = null, IReadOnlyList<string>? digests = null)
|
||||||
|
{
|
||||||
|
var body = new byte[26 + frames.Count * 36];
|
||||||
|
frames[^1][..26].CopyTo(body, 0);
|
||||||
|
for (int i = 0; i < frames.Count; i++)
|
||||||
|
{
|
||||||
|
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(26 + i * 36), (uint)(frames[i].Length - 26));
|
||||||
|
Convert.FromHexString(digests?[i] ?? Convert.ToHexStringLower(SHA256.HashData(frames[i]))).CopyTo(body, 26 + i * 36 + 4);
|
||||||
|
}
|
||||||
|
var register = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(body));
|
||||||
|
if (maxDownloads is not null) register.Headers.Add("X-WD-Max-Downloads", maxDownloads);
|
||||||
|
foreach (var (name, value) in signed ?? []) register.Headers.Add(name, value);
|
||||||
|
return await _main.Http.SendAsync(register);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>上传一个文件:每一帧先存进文件域,全部存好后一次到主后端登记(末帧的签名事件盖着所有帧的摘要)</summary>
|
||||||
|
private async Task<HttpStatusCode> UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList<byte[]> frames, string? maxDownloads = null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < frames.Count; i++) await StoreAsync(ch, item, frames[i], (uint)i);
|
||||||
|
using var response = await RegisterAsync(ch, frames, maxDownloads, SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", frames));
|
||||||
|
return response.StatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(HttpResponseMessage Response, byte[] Body)> ReadFromFilesAsync(string domain, string item, string ticket, uint from)
|
||||||
|
{
|
||||||
|
using var get = new HttpRequestMessage(HttpMethod.Get, $"/file/v1/domains/{domain}/items/{Hex(item)}/frames?from={from}");
|
||||||
|
get.Headers.Add("X-WD-Ticket", ticket);
|
||||||
|
var response = await _fileHttp.SendAsync(get);
|
||||||
|
return (response, await response.Content.ReadAsByteArrayAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill);
|
||||||
|
|
||||||
|
private static string Hex(string base64UrlId)
|
||||||
|
{
|
||||||
|
Assert.True(Codec.TryDecodeExact(base64UrlId, 16, out var bytes));
|
||||||
|
return Convert.ToHexStringLower(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───── 测试 ─────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FileBytesLiveInTheFileDomainWhileChatStaysOnTheHost()
|
||||||
|
{
|
||||||
|
var info = await _main.Http.GetStringAsync("/api/v1/info");
|
||||||
|
Assert.Contains("\"files\":{\"enabled\":true", info);
|
||||||
|
Assert.DoesNotContain("threshold", info);
|
||||||
|
|
||||||
|
var ch = await CreateSpaceAsync();
|
||||||
|
string channelId = await PrepareAsync(ch);
|
||||||
|
string item = NewId(), tiny = NewId(), chat = NewId();
|
||||||
|
await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, tiny); await DeclareAsync(ch, channelId, chat, "chat");
|
||||||
|
var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) };
|
||||||
|
var tinyFrame = Small(tiny, 7);
|
||||||
|
|
||||||
|
// 文件域的对象不能把字节直接交给主后端,不管多大
|
||||||
|
using (var direct = await _main.AppendAsync(ch, Owner(ch), frames[0], maxDownloads: "1")) Assert.Equal(HttpStatusCode.Conflict, direct.StatusCode);
|
||||||
|
using (var directSmall = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(tinyFrame)))
|
||||||
|
{
|
||||||
|
foreach (var (name, value) in SignedHeaders(ch.Id, channelId, tiny, CryptoDomain.Files, "file.publish", [tinyFrame])) directSmall.Headers.Add(name, value);
|
||||||
|
using var rejected = await _main.Http.SendAsync(directSmall);
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, rejected.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "1"));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, tiny, [tinyFrame]));
|
||||||
|
|
||||||
|
// 聊天(聊天域的对象)照旧存在主后端,日志里带着内容
|
||||||
|
var chatFrame = Small(chat, 5);
|
||||||
|
using (var chatRequest = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(chatFrame)))
|
||||||
|
{
|
||||||
|
foreach (var (name, value) in SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame])) chatRequest.Headers.Add(name, value);
|
||||||
|
using var chatResponse = await _main.Http.SendAsync(chatRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, chatResponse.StatusCode);
|
||||||
|
}
|
||||||
|
var (_, logBody) = await _main.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", Owner(ch));
|
||||||
|
var log = ParseLog(logBody);
|
||||||
|
Assert.Equal(2, log.Count(r => r.Kind == LogKind.Announce)); // 两个文件条目都只是「公布」
|
||||||
|
Assert.Equal(1, log.Count(r => r.Kind == LogKind.Frame)); // 只有聊天帧带内容
|
||||||
|
await AssertErrorAsync(await _main.Http.SendAsync(_main.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/items/{item}/frames", Owner(ch))), 409, "use_file_api");
|
||||||
|
|
||||||
|
// 受限条目:要票据,按原来的规则计次(上限 1 次)
|
||||||
|
var grant = await GrantAsync(ch, "get", item);
|
||||||
|
Assert.Equal(_domains[ch.Id], grant.Domain);
|
||||||
|
var (read, body) = await ReadFromFilesAsync(grant.Domain, item, grant.Ticket, 0);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, read.StatusCode);
|
||||||
|
Assert.Equal(frames.SelectMany(f => f).ToArray(), body);
|
||||||
|
using (var second = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.Gone, second.StatusCode);
|
||||||
|
var resume = await GrantAsync(ch, "get", item, 2);
|
||||||
|
Assert.Equal(frames[2], (await ReadFromFilesAsync(resume.Domain, item, resume.Ticket, 2)).Body);
|
||||||
|
|
||||||
|
// 单帧不限次的文件:想读几次都行
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var t = await GrantAsync(ch, "get", tiny);
|
||||||
|
Assert.Equal(tinyFrame, (await ReadFromFilesAsync(t.Domain, tiny, t.Ticket, 0)).Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天条目没有文件域,不发文件票据
|
||||||
|
using (var chatGrant = await GrantRequestAsync(ch, "get", chat)) Assert.Equal(HttpStatusCode.NotFound, chatGrant.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TicketsFollowTheSpacePermissionsAndAreBoundToTheirItem()
|
||||||
|
{
|
||||||
|
var ch = await CreateSpaceAsync();
|
||||||
|
string channelId = await PrepareAsync(ch);
|
||||||
|
string item = NewId(), other = NewId();
|
||||||
|
await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, other);
|
||||||
|
var f0 = Frame(item, 0, GatedEnc, EncPayload, 1);
|
||||||
|
var f1 = Frame(item, 1, GatedFinalEnc, EncPayload, 2);
|
||||||
|
|
||||||
|
// 没有空间里的身份,什么票据都拿不到
|
||||||
|
var stranger = ch.Owner with { Token = new byte[] { 1, 2, 3 }, AccountToken = null };
|
||||||
|
using (var denied = await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", stranger,
|
||||||
|
new StringContent(JsonSerializer.Serialize(new { op = "create" }), Encoding.UTF8, "application/json"))))
|
||||||
|
Assert.False(denied.IsSuccessStatusCode);
|
||||||
|
|
||||||
|
// 别的条目的票据传不进这个条目;没有票据也不行
|
||||||
|
var otherGrant = await GrantAsync(ch, "put", other);
|
||||||
|
using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(f0) })
|
||||||
|
{
|
||||||
|
put.Headers.Add("X-WD-Ticket", otherGrant.Ticket);
|
||||||
|
using var wrongItem = await _fileHttp.SendAsync(put);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, wrongItem.StatusCode);
|
||||||
|
}
|
||||||
|
using (var noTicket = await _fileHttp.PutAsync($"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0", new ByteArrayContent(f0))) Assert.Equal(HttpStatusCode.Unauthorized, noTicket.StatusCode);
|
||||||
|
|
||||||
|
// 同一个序号不能存不同的内容;同样的内容重传是幂等的
|
||||||
|
string digest0 = await StoreAsync(ch, item, f0, 0);
|
||||||
|
var grant = await GrantAsync(ch, "put", item);
|
||||||
|
using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 9)) })
|
||||||
|
{
|
||||||
|
put.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||||
|
using var conflict = await _fileHttp.SendAsync(put);
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||||
|
}
|
||||||
|
Assert.Equal(digest0, await StoreAsync(ch, item, f0, 0));
|
||||||
|
|
||||||
|
// 登记整个文件(一次请求):先把第二帧也存进文件域
|
||||||
|
string digest1 = await StoreAsync(ch, item, f1, 1);
|
||||||
|
// 末帧的签名清单盖着所有帧的摘要:登记的摘要和清单对不上,整个文件就不被接受
|
||||||
|
using (var wrongDigest = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, digest0]))
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, wrongDigest.StatusCode);
|
||||||
|
using (var good = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1])))
|
||||||
|
Assert.Equal(HttpStatusCode.Created, good.StatusCode);
|
||||||
|
// 同一个文件重复登记幂等;摘要不同(同一个条目)就是冲突
|
||||||
|
using (var again = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]))) Assert.True(again.IsSuccessStatusCode);
|
||||||
|
using (var different = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, new string('a', 64)]))
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, different.StatusCode);
|
||||||
|
// 请求体格式不对
|
||||||
|
using (var malformed = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(new byte[10])))
|
||||||
|
using (var bad = await _main.Http.SendAsync(malformed)) Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||||
|
|
||||||
|
// 不是文件域的条目不能走登记
|
||||||
|
string chat = NewId();
|
||||||
|
await DeclareAsync(ch, channelId, chat, "chat");
|
||||||
|
var chatFrame = Small(chat);
|
||||||
|
using (var notAFile = await RegisterAsync(ch, [chatFrame], null, SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame])))
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, notAFile.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ItemsInAChannelWithoutAFileDomainGetNoTicketsAndNeverRegisteredFilesCannotBeDownloaded()
|
||||||
|
{
|
||||||
|
var ch = await CreateSpaceAsync();
|
||||||
|
string channelId = await PrepareAsync(ch);
|
||||||
|
string item = NewId();
|
||||||
|
await DeclareAsync(ch, channelId, item);
|
||||||
|
// 字节存进了文件域、但没有到主后端登记的文件(上传到一半放弃):主后端不认识它,不发下载票据
|
||||||
|
await StoreAsync(ch, item, Frame(item, 0, GatedEnc, EncPayload, 1), 0);
|
||||||
|
using (var notRegistered = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.NotFound, notRegistered.StatusCode);
|
||||||
|
|
||||||
|
// 第二个空间里的默认频道没有文件域:声明了文件对象也要不到上传票据
|
||||||
|
var bare = await CreateSpaceAsync();
|
||||||
|
string bareChannel;
|
||||||
|
using (var request = _main.Req(HttpMethod.Get, $"/api/v1/spaces/{bare.Id}/structure", Owner(bare)))
|
||||||
|
using (var response = await _main.Http.SendAsync(request))
|
||||||
|
bareChannel = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
|
||||||
|
foreach (var domain in new[] { "chat", "files" })
|
||||||
|
{
|
||||||
|
using var bootstrap = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{bare.Id}/crypto/domains", Owner(bare),
|
||||||
|
JsonBody(new { channelId = bareChannel, domain, epoch = 1, envelopes = new[] { new { channelId = bareChannel, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } }));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode);
|
||||||
|
}
|
||||||
|
string orphan = NewId();
|
||||||
|
await DeclareAsync(bare, bareChannel, orphan);
|
||||||
|
using (var noDomain = await GrantRequestAsync(bare, "put", orphan)) Assert.Equal(HttpStatusCode.Conflict, noDomain.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletingAFileTakesAGrantThenDeletesOnTheHostAndInTheFileDomain()
|
||||||
|
{
|
||||||
|
var ch = await CreateSpaceAsync();
|
||||||
|
string channelId = await PrepareAsync(ch);
|
||||||
|
var store = _files.Services.GetRequiredService<FrameStore>();
|
||||||
|
|
||||||
|
string finished = NewId();
|
||||||
|
await DeclareAsync(ch, channelId, finished);
|
||||||
|
var finishedFrame = Small(finished, 3);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, finished, [finishedFrame]));
|
||||||
|
string domain = _domains[ch.Id];
|
||||||
|
Assert.True(store.UsedBy(domain) > 0);
|
||||||
|
|
||||||
|
foreach (var item in new[] { finished })
|
||||||
|
{
|
||||||
|
// 先向空间要「删除」票据(主后端确认能删),再删主后端的条目,最后用票据清文件域里的字节
|
||||||
|
var grant = await GrantAsync(ch, "delete", item);
|
||||||
|
using (var hostDelete = _main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", Owner(ch)))
|
||||||
|
{
|
||||||
|
// 删除文件要带作者设备签名的 file.delete 事件(清单和发布时一致)
|
||||||
|
foreach (var (name, value) in SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.delete", [finishedFrame])) hostDelete.Headers.Add(name, value);
|
||||||
|
using var host = await _main.Http.SendAsync(hostDelete);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, host.StatusCode);
|
||||||
|
}
|
||||||
|
using var remove = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}");
|
||||||
|
remove.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||||
|
using var removed = await _fileHttp.SendAsync(remove);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
|
||||||
|
}
|
||||||
|
Assert.Equal(0, store.UsedBy(domain));
|
||||||
|
|
||||||
|
// 取消上传:主后端那边还没有这个条目(整个文件传完才登记),用上传这个条目的票据就能清掉自己传的字节
|
||||||
|
string cancelled = NewId();
|
||||||
|
await DeclareAsync(ch, channelId, cancelled);
|
||||||
|
await StoreAsync(ch, cancelled, Frame(cancelled, 0, GatedEnc, EncPayload, 5), 0);
|
||||||
|
Assert.True(store.UsedBy(domain) > 0);
|
||||||
|
var put = await GrantAsync(ch, "put", cancelled);
|
||||||
|
using var abandon = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{put.Domain}/items/{Hex(cancelled)}");
|
||||||
|
abandon.Headers.Add("X-WD-Ticket", put.Ticket);
|
||||||
|
using var abandoned = await _fileHttp.SendAsync(abandon);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, abandoned.StatusCode);
|
||||||
|
Assert.Equal(0, store.UsedBy(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AFileUploadAsTheClientDoesItCompletesWithCatalogAndLocation()
|
||||||
|
{
|
||||||
|
var ch = await CreateSpaceAsync();
|
||||||
|
string channelId = await PrepareAsync(ch);
|
||||||
|
string item = NewId(), catalog = NewId();
|
||||||
|
await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, catalog);
|
||||||
|
var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) };
|
||||||
|
|
||||||
|
// 1. 字节进文件域,整个文件一次登记
|
||||||
|
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "0"));
|
||||||
|
// 2. 目录条目:一个控制帧(小,不受限),走主后端,带 file.catalog 签名事件
|
||||||
|
var control = Frame(catalog, 0, 16 | 8 | 1, EncPayload, 7);
|
||||||
|
using (var request = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(control)))
|
||||||
|
{
|
||||||
|
foreach (var (name, value) in SignedHeaders(ch.Id, channelId, catalog, CryptoDomain.Files, "file.catalog", [control])) request.Headers.Add(name, value);
|
||||||
|
using var response = await _main.Http.SendAsync(request);
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"目录条目被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||||
|
}
|
||||||
|
// 3. 位置登记:文件放进频道
|
||||||
|
using (var location = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/locations", Owner(ch), JsonBody(new { itemIds = new[] { item }, threadId = channelId })))
|
||||||
|
using (var response = await _main.Http.SendAsync(location))
|
||||||
|
Assert.True(response.IsSuccessStatusCode, $"位置登记被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using WebDrop.FileApi;
|
||||||
|
using WebDrop.FileProtocol;
|
||||||
|
|
||||||
|
namespace WebDrop.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>文件域是 fileAPI 自己的概念:创建、查询、容量、删除都由它负责,凭空间签发的票据访问。</summary>
|
||||||
|
public sealed class FileDomainTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string Secret = "domain-test-secret-0123456789abcdef0123";
|
||||||
|
private static readonly byte[] SecretBytes = Encoding.UTF8.GetBytes(Secret);
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "wd-domain-" + Guid.NewGuid().ToString("N"));
|
||||||
|
private WebApplication _files = null!;
|
||||||
|
private HttpClient _http = null!;
|
||||||
|
private readonly string _space = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_files = FileApiApp.Build([], b =>
|
||||||
|
{
|
||||||
|
b.Configuration["FileApi:DataDir"] = _dir;
|
||||||
|
b.Configuration["FileApi:Secret"] = Secret;
|
||||||
|
b.Configuration["FileApi:QuotaChoices:0"] = "2048";
|
||||||
|
b.Configuration["FileApi:QuotaChoices:1"] = "8192";
|
||||||
|
b.Configuration["FileApi:DefaultQuota"] = "2048";
|
||||||
|
b.Configuration["FileApi:CorsOrigins:0"] = "http://localhost:5173";
|
||||||
|
b.Configuration["urls"] = "http://127.0.0.1:0";
|
||||||
|
b.Logging.ClearProviders();
|
||||||
|
});
|
||||||
|
await _files.StartAsync();
|
||||||
|
_http = new HttpClient { BaseAddress = new Uri(_files.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_http.Dispose();
|
||||||
|
await _files.StopAsync();
|
||||||
|
await _files.DisposeAsync();
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>票据默认是本测试的空间签发的(管理票据不属于任何空间)</summary>
|
||||||
|
private string Ticket(FileOp op, string channel, string item = "*", string? space = null) =>
|
||||||
|
FileTokens.Sign(SecretBytes, new FileTicket(op, channel, item, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + 60_000, space ?? (op == FileOp.Admin ? "" : _space)));
|
||||||
|
|
||||||
|
private HttpRequestMessage Request(HttpMethod method, string url, string ticket, HttpContent? content = null)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, url) { Content = content };
|
||||||
|
request.Headers.Add(FileWire.TicketHeader, ticket);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FileDomainBody> CreateAsync(long quota = 0)
|
||||||
|
{
|
||||||
|
using var request = Request(HttpMethod.Post, "/file/v1/domains", Ticket(FileOp.Create, _space), JsonContent.Create(new FileDomainCreate(quota), options: null));
|
||||||
|
using var response = await _http.SendAsync(request);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
|
return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), FileProtocolJson.Default.FileDomainBody)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Frame(string itemHex, uint index, int payload)
|
||||||
|
{
|
||||||
|
var frame = new byte[FrameHeader.Size + payload];
|
||||||
|
frame[0] = FrameHeader.Version;
|
||||||
|
frame[1] = FrameHeader.FinalFlag;
|
||||||
|
Convert.FromHexString(itemHex).CopyTo(frame, 2);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(18), index);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(22), (uint)payload);
|
||||||
|
RandomNumberGenerator.Fill(frame.AsSpan(FrameHeader.Size));
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<HttpResponseMessage> PutAsync(string domain, string item, uint index, byte[] frame, string? ticket = null) =>
|
||||||
|
_http.SendAsync(Request(HttpMethod.Put, $"/file/v1/domains/{domain}/items/{item}/frames/{index}", ticket ?? Ticket(FileOp.Put, domain, item), new ByteArrayContent(frame)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OptionsAreAPublicStatementOfWhatTheFileServiceOffers()
|
||||||
|
{
|
||||||
|
var options = JsonSerializer.Deserialize(await _http.GetStringAsync("/file/v1/options"), FileProtocolJson.Default.FileDomainOptions)!;
|
||||||
|
Assert.Equal([2048L, 8192L], options.QuotaChoices);
|
||||||
|
Assert.Equal(2048, options.DefaultQuota);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnlyASpaceIssuedCreateTicketCreatesADomainAndTheQuotaMustBeOneOfTheChoices()
|
||||||
|
{
|
||||||
|
var domain = await CreateAsync(8192);
|
||||||
|
Assert.Equal(8192, domain.Quota);
|
||||||
|
Assert.Equal(_space, domain.Space);
|
||||||
|
Assert.True(FileWire.IsHex32(domain.Id));
|
||||||
|
Assert.Equal(2048, (await CreateAsync()).Quota); // 不选用默认值
|
||||||
|
|
||||||
|
using (var odd = Request(HttpMethod.Post, "/file/v1/domains", Ticket(FileOp.Create, _space), JsonContent.Create(new FileDomainCreate(12345))))
|
||||||
|
using (var rejected = await _http.SendAsync(odd)) Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
|
||||||
|
// 读写票据、没有票据都不能创建
|
||||||
|
using (var wrong = Request(HttpMethod.Post, "/file/v1/domains", Ticket(FileOp.Put, _space, "*"), JsonContent.Create(new FileDomainCreate())))
|
||||||
|
using (var denied = await _http.SendAsync(wrong)) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
|
||||||
|
using (var anonymous = await _http.PostAsync("/file/v1/domains", JsonContent.Create(new FileDomainCreate()))) Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WritesAreCountedAgainstTheDomainQuotaAndOverflowIsRefused()
|
||||||
|
{
|
||||||
|
var domain = await CreateAsync(); // 2048 字节
|
||||||
|
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), other = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var first = await PutAsync(domain.Id, item, 0, Frame(item, 0, 1000))) Assert.Equal(HttpStatusCode.OK, first.StatusCode); // 1026 字节
|
||||||
|
using (var query = Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}", Ticket(FileOp.Get, domain.Id)))
|
||||||
|
using (var info = await _http.SendAsync(query))
|
||||||
|
{
|
||||||
|
var body = JsonSerializer.Deserialize(await info.Content.ReadAsStringAsync(), FileProtocolJson.Default.FileDomainBody)!;
|
||||||
|
Assert.Equal(1026, body.Used);
|
||||||
|
Assert.Equal(2048, body.Quota);
|
||||||
|
}
|
||||||
|
using (var second = await PutAsync(domain.Id, other, 0, Frame(other, 0, 1000))) Assert.Equal(HttpStatusCode.InsufficientStorage, second.StatusCode); // 再存会超出
|
||||||
|
using (var small = await PutAsync(domain.Id, other, 0, Frame(other, 0, 500))) Assert.Equal(HttpStatusCode.OK, small.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ATicketOnlyWorksForItsOwnDomainAndUnknownDomainsDoNotExist()
|
||||||
|
{
|
||||||
|
var a = await CreateAsync(); var b = await CreateAsync();
|
||||||
|
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var cross = await PutAsync(b.Id, item, 0, Frame(item, 0, 10), Ticket(FileOp.Put, a.Id, item))) Assert.Equal(HttpStatusCode.Unauthorized, cross.StatusCode);
|
||||||
|
string ghost = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var missing = await PutAsync(ghost, item, 0, Frame(item, 0, 10))) Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode);
|
||||||
|
using (var read = Request(HttpMethod.Get, $"/file/v1/domains/{ghost}", Ticket(FileOp.Get, ghost)))
|
||||||
|
using (var notFound = await _http.SendAsync(read)) Assert.Equal(HttpStatusCode.Unauthorized, notFound.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletingADomainRemovesItsFilesAndOnlyAnAdminTicketMay()
|
||||||
|
{
|
||||||
|
var domain = await CreateAsync();
|
||||||
|
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
|
||||||
|
using (var denied = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}", Ticket(FileOp.Put, domain.Id, item)))) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
|
||||||
|
using (var removed = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}", Ticket(FileOp.Admin, "*")))) Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
|
||||||
|
using (var gone = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.Unauthorized, gone.StatusCode);
|
||||||
|
using (var read = await _http.SendAsync(Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}/items/{item}/frames", Ticket(FileOp.Get, domain.Id, item)))) Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ATicketFromAnotherSpaceIsRefusedEvenIfItNamesTheDomain()
|
||||||
|
{
|
||||||
|
var domain = await CreateAsync(); // 属于 _space
|
||||||
|
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
string otherSpace = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var foreign = await PutAsync(domain.Id, item, 0, Frame(item, 0, 10), Ticket(FileOp.Put, domain.Id, item, otherSpace))) Assert.Equal(HttpStatusCode.Unauthorized, foreign.StatusCode);
|
||||||
|
using (var own = await PutAsync(domain.Id, item, 0, Frame(item, 0, 10))) Assert.Equal(HttpStatusCode.OK, own.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AReadTicketWorksOnlyOnceSoReplayingItCannotBypassTheDownloadCount()
|
||||||
|
{
|
||||||
|
var domain = await CreateAsync();
|
||||||
|
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
|
||||||
|
string ticket = Ticket(FileOp.Get, domain.Id, item);
|
||||||
|
using (var first = await _http.SendAsync(Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}/items/{item}/frames", ticket))) Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||||
|
using (var replay = await _http.SendAsync(Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}/items/{item}/frames", ticket))) Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode);
|
||||||
|
// 上传票据一个条目的所有帧共用,不受一次性限制
|
||||||
|
string put = Ticket(FileOp.Put, domain.Id, item);
|
||||||
|
using (var f1 = await PutAsync(domain.Id, item, 1, Frame(item, 1, 50), put)) Assert.Equal(HttpStatusCode.OK, f1.StatusCode);
|
||||||
|
using (var f2 = await PutAsync(domain.Id, item, 2, Frame(item, 2, 50), put)) Assert.Equal(HttpStatusCode.OK, f2.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUploadStaysPendingUntilCommittedAndAbandonedOnesAreSwept()
|
||||||
|
{
|
||||||
|
var domain = await CreateAsync();
|
||||||
|
var store = _files.Services.GetRequiredService<FrameStore>();
|
||||||
|
string kept = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), abandoned = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), old = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
foreach (var item in new[] { kept, abandoned })
|
||||||
|
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
|
||||||
|
|
||||||
|
// 都还待确认;确认了一个之后,它不再被当成没人要的
|
||||||
|
Assert.Contains($"{domain.Id}/{kept}", store.StalePending(TimeSpan.Zero));
|
||||||
|
using (var commit = await _http.SendAsync(Request(HttpMethod.Post, $"/file/v1/domains/{domain.Id}/items/{kept}/commit", Ticket(FileOp.Put, domain.Id, kept)))) Assert.Equal(HttpStatusCode.NoContent, commit.StatusCode);
|
||||||
|
Assert.DoesNotContain($"{domain.Id}/{kept}", store.StalePending(TimeSpan.Zero));
|
||||||
|
Assert.Contains($"{domain.Id}/{abandoned}", store.StalePending(TimeSpan.Zero));
|
||||||
|
// 读取票据不能确认
|
||||||
|
using (var denied = await _http.SendAsync(Request(HttpMethod.Post, $"/file/v1/domains/{domain.Id}/items/{abandoned}/commit", Ticket(FileOp.Get, domain.Id, abandoned)))) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
|
||||||
|
|
||||||
|
// 没确认的过了保留期被清理,确认过的和旧版本留下的(没有标记)不受影响
|
||||||
|
Directory.CreateDirectory(Path.Combine(_dir, "frames", domain.Id, old));
|
||||||
|
File.WriteAllBytes(Path.Combine(_dir, "frames", domain.Id, old, "0000000000"), new byte[10]);
|
||||||
|
var sweeper = _files.Services.GetServices<Microsoft.Extensions.Hosting.IHostedService>().OfType<PendingSweeper>().Single();
|
||||||
|
foreach (var path in Directory.EnumerateFiles(Path.Combine(_dir, "frames", domain.Id, abandoned))) File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddDays(-2));
|
||||||
|
Assert.Equal(1, sweeper.SweepOnce());
|
||||||
|
Assert.False(Directory.Exists(Path.Combine(_dir, "frames", domain.Id, abandoned)));
|
||||||
|
Assert.True(Directory.Exists(Path.Combine(_dir, "frames", domain.Id, kept)));
|
||||||
|
Assert.True(Directory.Exists(Path.Combine(_dir, "frames", domain.Id, old)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellingAnUploadWithTheUploadTicketFreesTheDomainSpace()
|
||||||
|
{
|
||||||
|
var domain = await CreateAsync();
|
||||||
|
var store = _files.Services.GetRequiredService<FrameStore>();
|
||||||
|
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 500))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
|
||||||
|
Assert.True(store.UsedBy(domain.Id) > 0);
|
||||||
|
using (var cancelled = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}/items/{item}", Ticket(FileOp.Put, domain.Id, item)))) Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
||||||
|
Assert.Equal(0, store.UsedBy(domain.Id));
|
||||||
|
// 读取票据不能拿来删
|
||||||
|
using (var again = await PutAsync(domain.Id, item, 0, Frame(item, 0, 500))) Assert.Equal(HttpStatusCode.OK, again.StatusCode);
|
||||||
|
using (var denied = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}/items/{item}", Ticket(FileOp.Get, domain.Id, item)))) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("POST")]
|
||||||
|
[InlineData("PUT")]
|
||||||
|
[InlineData("DELETE")]
|
||||||
|
[InlineData("GET")]
|
||||||
|
public async Task BrowserPreflightAllowsEveryMethodTheFrontendUses(string method)
|
||||||
|
{
|
||||||
|
// 浏览器直连文件服务:创建文件域、确认条目是 POST,取消和删除是 DELETE,不在预检允许的方法里会被浏览器拦下
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Options, "/file/v1/domains");
|
||||||
|
request.Headers.Add("Origin", "http://localhost:5173");
|
||||||
|
request.Headers.Add("Access-Control-Request-Method", method);
|
||||||
|
request.Headers.Add("Access-Control-Request-Headers", "x-wd-ticket,content-type");
|
||||||
|
using var response = await _http.SendAsync(request);
|
||||||
|
Assert.Contains(method, response.Headers.GetValues("Access-Control-Allow-Methods").Single().Split(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DomainUsageSurvivesARestartAndStaysCorrectAfterADelete()
|
||||||
|
{
|
||||||
|
// 重启之后用量从磁盘重新统计:先删一个条目,剩下的条目不能被算两次扣减
|
||||||
|
var domain = await CreateAsync();
|
||||||
|
string a = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), b = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
|
||||||
|
using (var one = await PutAsync(domain.Id, a, 0, Frame(a, 0, 1000))) Assert.Equal(HttpStatusCode.OK, one.StatusCode);
|
||||||
|
using (var two = await PutAsync(domain.Id, b, 0, Frame(b, 0, 300))) Assert.Equal(HttpStatusCode.OK, two.StatusCode);
|
||||||
|
var restarted = new FrameStore(Path.Combine(_dir, "frames"), 0, Path.Combine(_dir, "sync-restarted"));
|
||||||
|
Assert.True(restarted.DeletePrefix($"{domain.Id}/{a}"));
|
||||||
|
Assert.Equal(26 + 300, restarted.UsedBy(domain.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using WebDrop.Core;
|
||||||
|
using WebDrop.FileApi;
|
||||||
|
using WebDrop.FileProtocol;
|
||||||
|
using static WebDrop.Api.Tests.ServerFixture;
|
||||||
|
|
||||||
|
namespace WebDrop.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>文件服务把新增的帧同步到外部存储(S3 协议):增量、可重试、删除后保留一段时间再清理。</summary>
|
||||||
|
public sealed class FileSyncTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string Secret = "sync-test-secret-0123456789abcdef-xyz-0123";
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-sync-" + Guid.NewGuid().ToString("N"));
|
||||||
|
private readonly FakeS3 _s3 = new();
|
||||||
|
private WebApplication _files = null!;
|
||||||
|
private HttpClient _http = null!;
|
||||||
|
private string _channel = "";
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _s3.StartAsync();
|
||||||
|
_files = FileApiApp.Build([], b =>
|
||||||
|
{
|
||||||
|
b.Configuration["FileApi:DataDir"] = _dir;
|
||||||
|
b.Configuration["FileApi:Secret"] = Secret;
|
||||||
|
b.Configuration["FileApi:Backup:Enabled"] = "false"; // 定时任务不在测试里跑,测试手动触发
|
||||||
|
b.Configuration["FileApi:Backup:RetainDays"] = "30";
|
||||||
|
b.Configuration["FileApi:Backup:S3:Endpoint"] = _s3.Url;
|
||||||
|
b.Configuration["FileApi:Backup:S3:AccessKey"] = FakeS3.AccessKey;
|
||||||
|
b.Configuration["FileApi:Backup:S3:SecretKey"] = FakeS3.SecretKey;
|
||||||
|
b.Configuration["FileApi:Backup:S3:BasePath"] = "webdrop";
|
||||||
|
b.Configuration["FileApi:Backup:S3:Bucket"] = FakeS3.Bucket;
|
||||||
|
b.Configuration["urls"] = "http://127.0.0.1:0";
|
||||||
|
b.Logging.ClearProviders();
|
||||||
|
});
|
||||||
|
await _files.StartAsync();
|
||||||
|
_http = new HttpClient { BaseAddress = new Uri(_files.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||||
|
// 帧都存在一个文件域里:先用「创建」票据建一个
|
||||||
|
string space = Convert.ToHexStringLower(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16));
|
||||||
|
using var create = new HttpRequestMessage(HttpMethod.Post, "/file/v1/domains") { Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") };
|
||||||
|
create.Headers.Add("X-WD-Ticket", FileTokens.Sign(System.Text.Encoding.UTF8.GetBytes(Secret), new FileTicket(FileOp.Create, space, "*", 0, DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds())));
|
||||||
|
using var created = await _http.SendAsync(create);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||||
|
_channel = System.Text.Json.JsonSerializer.Deserialize(await created.Content.ReadAsStringAsync(), FileProtocolJson.Default.FileDomainBody)!.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_http.Dispose();
|
||||||
|
await _files.StopAsync(); await _files.DisposeAsync(); await _s3.DisposeAsync();
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hex(string id) { Codec.TryDecodeExact(id, 16, out var b); return Convert.ToHexStringLower(b); }
|
||||||
|
|
||||||
|
private string Admin() => FileTokens.Sign(System.Text.Encoding.UTF8.GetBytes(Secret), new FileTicket(FileOp.Admin, "*", "*", 0, DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds()));
|
||||||
|
|
||||||
|
private async Task<byte[]> PutAsync(string item, uint index, byte fill)
|
||||||
|
{
|
||||||
|
var frame = Frame(item, index, GatedEnc, EncPayload, fill);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{_channel}/items/{Hex(item)}/frames/{index}") { Content = new ByteArrayContent(frame) };
|
||||||
|
request.Headers.Add("X-WD-Ticket", Admin());
|
||||||
|
using var response = await _http.SendAsync(request);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
// 确认这个条目(已登记到主后端):没确认的待确认条目不同步
|
||||||
|
using var commit = new HttpRequestMessage(HttpMethod.Post, $"/file/v1/domains/{_channel}/items/{Hex(item)}/commit");
|
||||||
|
commit.Headers.Add("X-WD-Ticket", Admin());
|
||||||
|
using var committed = await _http.SendAsync(commit);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, committed.StatusCode);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SyncWorker Worker => _files.Services.GetRequiredService<SyncWorker>();
|
||||||
|
private string Remote(string item, uint index) => $"webdrop/files/{_channel}/{Hex(item)}/{index:D10}";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NewFramesAreUploadedOnceAndTheRestResumesAfterAnOutage()
|
||||||
|
{
|
||||||
|
string a = NewId(), b = NewId();
|
||||||
|
var frames = new[] { await PutAsync(a, 0, 1), await PutAsync(a, 1, 2), await PutAsync(a, 2, 3), await PutAsync(b, 0, 4) };
|
||||||
|
|
||||||
|
// 外部存储暂时连不上:不会标记已同步,下次接着来
|
||||||
|
_s3.Down = true;
|
||||||
|
var down = await Worker.RunOnceAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(0, down.Uploaded); Assert.True(down.Failed > 0);
|
||||||
|
Assert.Empty(_s3.Files);
|
||||||
|
|
||||||
|
_s3.Down = false;
|
||||||
|
var first = await Worker.RunOnceAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(4, first.Uploaded);
|
||||||
|
Assert.Equal(frames[0], _s3.Files[Remote(a, 0)]);
|
||||||
|
Assert.Equal(frames[2], _s3.Files[Remote(a, 2)]);
|
||||||
|
Assert.Equal(frames[3], _s3.Files[Remote(b, 0)]);
|
||||||
|
|
||||||
|
// 增量:没有新内容就什么也不传;新来的帧只传新的
|
||||||
|
Assert.Equal(0, (await Worker.RunOnceAsync(CancellationToken.None)).Uploaded);
|
||||||
|
var more = await PutAsync(a, 3, 5);
|
||||||
|
var second = await Worker.RunOnceAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(1, second.Uploaded);
|
||||||
|
Assert.Equal(more, _s3.Files[Remote(a, 3)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletedContentStaysInTheBackupUntilTheRetentionPeriodPasses()
|
||||||
|
{
|
||||||
|
string a = NewId(), b = NewId();
|
||||||
|
await PutAsync(a, 0, 1); await PutAsync(b, 0, 2);
|
||||||
|
await Worker.RunOnceAsync(CancellationToken.None);
|
||||||
|
Assert.True(_s3.Files.ContainsKey(Remote(a, 0)));
|
||||||
|
|
||||||
|
// 在文件服务上删掉 a:本地没了,外部备份里还在(误删可以找回)
|
||||||
|
using (var delete = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{_channel}/items/{Hex(a)}"))
|
||||||
|
{
|
||||||
|
delete.Headers.Add("X-WD-Ticket", Admin());
|
||||||
|
using var response = await _http.SendAsync(delete);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
}
|
||||||
|
var early = await Worker.RunOnceAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(0, early.Purged);
|
||||||
|
Assert.True(_s3.Files.ContainsKey(Remote(a, 0)));
|
||||||
|
Assert.True(_s3.Files.ContainsKey(Remote(b, 0)));
|
||||||
|
|
||||||
|
// 删除记录满 30 天后,下一次同步才把外部备份里的也清掉(没删的 b 不受影响)
|
||||||
|
var log = Path.Combine(_dir, "sync", "deleted.log");
|
||||||
|
long old = DateTimeOffset.UtcNow.AddDays(-31).ToUnixTimeMilliseconds();
|
||||||
|
File.WriteAllLines(log, File.ReadAllLines(log).Select(l => l.Split('\t')[0] + "\t" + old));
|
||||||
|
var late = await Worker.RunOnceAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(1, late.Purged);
|
||||||
|
Assert.False(_s3.Files.ContainsKey(Remote(a, 0)));
|
||||||
|
Assert.True(_s3.Files.ContainsKey(Remote(b, 0)));
|
||||||
|
Assert.Empty(File.ReadAllLines(log));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContentDeletedBeforeItWasEverSyncedLeavesNothingToPurge()
|
||||||
|
{
|
||||||
|
string a = NewId();
|
||||||
|
await PutAsync(a, 0, 1);
|
||||||
|
using (var delete = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{_channel}/items/{Hex(a)}"))
|
||||||
|
{
|
||||||
|
delete.Headers.Add("X-WD-Ticket", Admin());
|
||||||
|
using var response = await _http.SendAsync(delete);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
}
|
||||||
|
var run = await Worker.RunOnceAsync(CancellationToken.None);
|
||||||
|
Assert.Equal(0, run.Uploaded);
|
||||||
|
Assert.Empty(_s3.Files);
|
||||||
|
Assert.False(File.Exists(Path.Combine(_dir, "sync", "deleted.log")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
|||||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-api-" + Guid.NewGuid().ToString("N"));
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-api-" + Guid.NewGuid().ToString("N"));
|
||||||
private readonly Dictionary<string, string> _overrides;
|
private readonly Dictionary<string, string> _overrides;
|
||||||
|
|
||||||
|
public string DataDir => _dir;
|
||||||
public WebApplication App { get; private set; } = null!;
|
public WebApplication App { get; private set; } = null!;
|
||||||
public HttpClient Http { get; private set; } = null!;
|
public HttpClient Http { get; private set; } = null!;
|
||||||
public Uri BaseUri { get; private set; } = null!;
|
public Uri BaseUri { get; private set; } = null!;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\WebDrop.Api\WebDrop.Api.csproj" />
|
<ProjectReference Include="..\..\src\WebDrop.Api\WebDrop.Api.csproj" />
|
||||||
<ProjectReference Include="..\..\src\WebDrop.Core\WebDrop.Core.csproj" />
|
<ProjectReference Include="..\..\src\WebDrop.Core\WebDrop.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\WebDrop.FileApi\WebDrop.FileApi.csproj" />
|
||||||
<ProjectReference Include="..\..\src\WebDrop.Storage\WebDrop.Storage.csproj" />
|
<ProjectReference Include="..\..\src\WebDrop.Storage\WebDrop.Storage.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using WebDrop.Core;
|
||||||
|
using WebDrop.Storage;
|
||||||
|
|
||||||
|
namespace WebDrop.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class AvatarFilesTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), "wd-avatar-" + Guid.NewGuid().ToString("N"));
|
||||||
|
public void Dispose() { SqliteConnection.ClearAllPools(); try { Directory.Delete(_dir, true); } catch (IOException) { } }
|
||||||
|
|
||||||
|
private static string Gif(byte fill) => "data:image/gif;base64," + Convert.ToBase64String([(byte)'G', (byte)'I', (byte)'F', fill, fill, fill, fill, fill]);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SavedAvatarsAreContentAddressedFilesAndResolveBack()
|
||||||
|
{
|
||||||
|
var files = new AvatarFiles(Path.Combine(_dir, "a"));
|
||||||
|
string first = files.Save(Gif(1)), again = files.Save(Gif(1)), other = files.Save(Gif(2));
|
||||||
|
Assert.Equal(first, again);
|
||||||
|
Assert.NotEqual(first, other);
|
||||||
|
var blob = files.Resolve(first)!;
|
||||||
|
Assert.Equal("image/gif", blob.Mime);
|
||||||
|
Assert.Equal(8, new FileInfo(blob.Path).Length);
|
||||||
|
Assert.Null(files.Resolve("../../etc/passwd"));
|
||||||
|
Assert.Null(files.Resolve(new string('0', 64) + ".gif"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyDataUrlRowsAreMovedToFilesOnOpenAndOrphansAreSwept()
|
||||||
|
{
|
||||||
|
string db = Path.Combine(_dir, "v2", "webdrop.db");
|
||||||
|
var id = Id16.FromBytes(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray());
|
||||||
|
using (var store = new SqliteChannelStore(db)) { }
|
||||||
|
using (var conn = new SqliteConnection($"Data Source={db}"))
|
||||||
|
{
|
||||||
|
conn.Open();
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO channel_avatars(space_id, avatar, thumb) VALUES ($c, $a, $t)";
|
||||||
|
cmd.Parameters.AddWithValue("$c", id.ToArray()); cmd.Parameters.AddWithValue("$a", Gif(3)); cmd.Parameters.AddWithValue("$t", Gif(4));
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
string avatars = Path.Combine(_dir, "v2", "avatars", "space");
|
||||||
|
File.WriteAllBytes(Path.Combine(avatars, "orphan.gif"), [1]);
|
||||||
|
using var reopened = new SqliteChannelStore(db);
|
||||||
|
var (full, thumb) = reopened.Read(tx => (tx.GetChannelAvatar(id, thumb: false), tx.GetChannelAvatar(id, thumb: true)));
|
||||||
|
Assert.NotNull(full); Assert.NotNull(thumb);
|
||||||
|
Assert.NotEqual(full!.Hash, thumb!.Hash);
|
||||||
|
Assert.False(File.Exists(Path.Combine(avatars, "orphan.gif")));
|
||||||
|
Assert.Equal(2, Directory.GetFiles(avatars).Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,13 @@ namespace WebDrop.Core.Tests;
|
|||||||
|
|
||||||
public sealed class ChannelCryptoTests
|
public sealed class ChannelCryptoTests
|
||||||
{
|
{
|
||||||
|
/// <summary>文件域的对象主后端只登记头部和摘要,字节不在这里:一个单帧文件</summary>
|
||||||
|
private static Task Register(ChannelService svc, Id16 id, Credentials creds, byte[] frame)
|
||||||
|
{
|
||||||
|
svc.RegisterFileItem(id, creds, frame.AsMemory(0, Envelope.HeaderSize), [new ChannelService.FrameEntry(frame.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(frame))], null);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
|
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
|
||||||
{
|
{
|
||||||
@@ -79,9 +86,10 @@ public sealed class ChannelCryptoTests
|
|||||||
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
|
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
|
||||||
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint);
|
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint);
|
||||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||||
|
env.Service.SetWorkspaceChannelFileDomain(space.Id, owner, channelId, "domain-1");
|
||||||
var oldFile = TestEnv.NewId();
|
var oldFile = TestEnv.NewId();
|
||||||
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||||
await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null);
|
await Register(env.Service, space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16));
|
||||||
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
|
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
|
||||||
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
|
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
|
||||||
|
|
||||||
@@ -98,22 +106,6 @@ public sealed class ChannelCryptoTests
|
|||||||
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
|
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AnUnfinishedUploadCanBeDeletedWithoutASignedEvent()
|
|
||||||
{
|
|
||||||
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
|
|
||||||
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint) with { RequireSignedContent = true };
|
|
||||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
|
||||||
var member = space.Member(memberEndpoint) with { AccountName = "member", RequireSignedContent = true }; env.Service.Join(space.Id, member);
|
|
||||||
var file = TestEnv.NewId();
|
|
||||||
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = file.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
|
||||||
// 只传了第 0 帧,没有末帧:上传被打断
|
|
||||||
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(file, 0, FrameFlags.Gated, 16), null);
|
|
||||||
Assert.Throws<WebDropException>(() => env.Service.DeleteItem(space.Id, space.Member(TestEnv.Endpoint()) with { AccountName = "stranger", RequireSignedContent = true }, file));
|
|
||||||
env.Service.DeleteItem(space.Id, owner, file);
|
|
||||||
Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites()
|
public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -159,25 +159,45 @@ public class ChannelServiceTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FileGroupQuotaIsPerWorkspaceChannelAndFreedOnDelete()
|
public async Task FileObjectBytesAreNeverStoredByTheHostAWholeFileIsRegisteredByDigestsInOneStep()
|
||||||
{
|
{
|
||||||
_env.Limits.MaxWorkspaceChannelBytes = 200;
|
|
||||||
var space = _env.Create(_ownerEp, ChannelMode.Raw);
|
var space = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||||
var owner = space.Owner(_ownerEp);
|
var owner = space.Owner(_ownerEp);
|
||||||
var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!;
|
var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!;
|
||||||
var first = TestEnv.NewId(); var second = TestEnv.NewId();
|
var item = TestEnv.NewId();
|
||||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = first.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = item.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = second.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
|
||||||
|
|
||||||
byte[] firstFrame = TestEnv.Frame(first, 0, FrameFlags.Final, 120);
|
// 频道没有文件域(文件服务没开、小文件):文件和聊天一样存在主后端,不能整个登记
|
||||||
await Svc.AppendFrameAsync(space.Id, owner, firstFrame, null);
|
var hosted = TestEnv.NewId();
|
||||||
Assert.Equal(firstFrame.Length, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = hosted.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||||
await AssertCode(ErrorCode.TooLarge, () => Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null));
|
Assert.True((await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(hosted, 0, FrameFlags.Gated | FrameFlags.Final, 60), 3)).Created);
|
||||||
|
var hostedFrame = TestEnv.Frame(hosted, 0, FrameFlags.Gated | FrameFlags.Final, 60);
|
||||||
|
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, hostedFrame.AsMemory(0, Envelope.HeaderSize),
|
||||||
|
[new ChannelService.FrameEntry(60, System.Security.Cryptography.SHA256.HashData(hostedFrame))], 3)).Code);
|
||||||
|
|
||||||
Svc.DeleteItem(space.Id, owner, first);
|
// 分配了文件域的频道:文件域的对象不能把字节直接交给主后端
|
||||||
Assert.Equal(0, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
Svc.SetWorkspaceChannelFileDomain(space.Id, owner, channelId, "domain-1");
|
||||||
await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null);
|
byte[] first = TestEnv.Frame(item, 0, FrameFlags.Gated, 120);
|
||||||
Assert.Equal(86, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
await AssertCode(ErrorCode.UseFileApi, () => Svc.AppendFrameAsync(space.Id, owner, first, null));
|
||||||
|
|
||||||
|
// 整个文件一次登记:末帧头部 + 每一帧的(负载长度,整帧摘要)
|
||||||
|
byte[][] frames = [first, TestEnv.Frame(item, 1, FrameFlags.Gated, 80), TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Final, 40)];
|
||||||
|
ChannelService.FrameEntry[] entries = frames.Select(f => new ChannelService.FrameEntry(f.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(f))).ToArray();
|
||||||
|
var header = frames[^1].AsMemory(0, Envelope.HeaderSize);
|
||||||
|
Assert.True(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created);
|
||||||
|
Assert.False(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created); // 同一个文件重复登记幂等
|
||||||
|
var other = entries.ToArray(); other[1] = new ChannelService.FrameEntry(other[1].PayloadLength, new byte[32]);
|
||||||
|
Assert.Equal(ErrorCode.Conflict, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, header, other, 3)).Code); // 摘要不同就是冲突
|
||||||
|
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, frames[0].AsMemory(0, Envelope.HeaderSize), entries, 3)).Code); // 头部必须是末帧
|
||||||
|
|
||||||
|
// 聊天域的对象不能这样登记(它们的字节就存在主后端)
|
||||||
|
var chatItem = TestEnv.NewId();
|
||||||
|
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = chatItem.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
|
||||||
|
var chatFrame = TestEnv.Frame(chatItem, 0, FrameFlags.Final, 40);
|
||||||
|
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, chatFrame.AsMemory(0, Envelope.HeaderSize),
|
||||||
|
[new ChannelService.FrameEntry(40, System.Security.Cryptography.SHA256.HashData(chatFrame))], null)).Code);
|
||||||
|
|
||||||
|
Svc.DeleteItem(space.Id, owner, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WebDrop.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权限规则只写在 ChannelPolicy 一处:别的地方只许调用,不许再各写一遍。
|
||||||
|
/// 这个测试扫描源码,发现在 ChannelPolicy 之外重新判断角色、读写授权就失败,防止重复实现长回来。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PolicySingleSourceTests
|
||||||
|
{
|
||||||
|
private static string SourceRoot()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "src", "WebDrop.Core"))) dir = dir.Parent;
|
||||||
|
return Path.Combine(dir?.FullName ?? throw new InvalidOperationException("找不到 server/src"), "src");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这些写法只能出现在 ChannelPolicy.cs(数据类的定义和令牌校验除外)
|
||||||
|
private static readonly (string Name, Regex Pattern)[] Forbidden =
|
||||||
|
[
|
||||||
|
("直接读频道访问授权的读写位", new Regex(@"\.(ChatRead|ChatWrite|FilesRead|FilesUpload)\b")),
|
||||||
|
("直接比较是不是所有者", new Regex(@"Role\s*[!=]=\s*Role\.Owner")),
|
||||||
|
("自己拼「所有者或创建者」", new Regex(@"AccountName\s*==\s*\w+\.CreatorAccount")),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 令牌校验(凭据层,不是授权规则)和数据定义,允许出现
|
||||||
|
private static readonly string[] Allowed =
|
||||||
|
["ChannelPolicy.cs", "WorkspaceCrypto.cs", "RequestAuth.cs", "ShareService.cs"];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthorizationRulesAreWrittenOnlyInChannelPolicy()
|
||||||
|
{
|
||||||
|
var offenders = new List<string>();
|
||||||
|
foreach (var file in Directory.EnumerateFiles(SourceRoot(), "*.cs", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
if (file.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) || file.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar)) continue;
|
||||||
|
if (Allowed.Contains(Path.GetFileName(file))) continue;
|
||||||
|
var lines = File.ReadAllLines(file);
|
||||||
|
for (int i = 0; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
if (lines[i].TrimStart().StartsWith("//") || lines[i].TrimStart().StartsWith("///")) continue;
|
||||||
|
foreach (var (name, pattern) in Forbidden)
|
||||||
|
if (pattern.IsMatch(lines[i])) offenders.Add($"{Path.GetFileName(file)}:{i + 1} {name}: {lines[i].Trim()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.True(offenders.Count == 0, "这些地方在 ChannelPolicy 之外重新判断了权限,请改成调用 ChannelPolicy:\n" + string.Join("\n", offenders));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,4 +148,54 @@ public sealed class WorkspaceStructureTests
|
|||||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主
|
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主
|
||||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "自封", null, null, null, []));
|
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "自封", null, null, null, []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChannelFileDomainIsAnOpaqueReferenceSetOnceAndNeverInterpreted()
|
||||||
|
{
|
||||||
|
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
|
||||||
|
Assert.Null(env.Service.GetWorkspace(ch.Id, owner).Channels.Single().FileDomain);
|
||||||
|
var created = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "文件", null, "ab12-CD_34").Channel;
|
||||||
|
Assert.Equal("ab12-CD_34", created.FileDomain);
|
||||||
|
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "乱", null, "bad domain!"));
|
||||||
|
|
||||||
|
// 创建时没带的,以后可以补一次;补了就不能换成别的(文件已经存在那里了)
|
||||||
|
var later = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "稍后", null).Channel;
|
||||||
|
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
|
||||||
|
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
|
||||||
|
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChannelInfoStatesWhatTheCallerMayDoSoClientsNeverDeriveIt()
|
||||||
|
{
|
||||||
|
var (env, ch, owner, alice, bob, carol) = RoleSpace(); using var _env = env;
|
||||||
|
var admins = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, null, null, [WorkspacePermission.RemoveMembers]).Role;
|
||||||
|
var movers = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "结构组", null, null, null, [WorkspacePermission.ManageStructure]).Role;
|
||||||
|
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admins.Id, ["alice", "bob"]);
|
||||||
|
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, movers.Id, ["carol"]);
|
||||||
|
|
||||||
|
// 所有者:全部能力
|
||||||
|
var asOwner = env.Service.GetChannel(ch.Id, owner);
|
||||||
|
Assert.True(asOwner.IsOwner); Assert.True(asOwner.CanManageStructure); Assert.True(asOwner.CanDeleteAny); Assert.True(asOwner.CanDeleteOwn);
|
||||||
|
Assert.True(asOwner.CanCreateChannel && asOwner.CanManageFolders && asOwner.CanInvite && asOwner.CanEditSettings && asOwner.CanRemoveMembers);
|
||||||
|
|
||||||
|
// 身份组授予的权限:被授予「管理频道和分组」的不是所有者,却能管理结构;其他所有者专属的操作它仍然做不了
|
||||||
|
var asCarol = env.Service.GetChannel(ch.Id, carol);
|
||||||
|
Assert.False(asCarol.IsOwner); Assert.True(asCarol.CanManageStructure); Assert.False(asCarol.CanRemoveMembers);
|
||||||
|
Assert.False(asCarol.CanCreateChannel || asCarol.CanManageFolders || asCarol.CanInvite || asCarol.CanEditSettings);
|
||||||
|
var asAlice = env.Service.GetChannel(ch.Id, alice);
|
||||||
|
Assert.False(asAlice.IsOwner); Assert.False(asAlice.CanManageStructure); Assert.True(asAlice.CanRemoveMembers);
|
||||||
|
// 默认删除策略:成员只能删自己的
|
||||||
|
Assert.False(asAlice.CanDeleteAny); Assert.True(asAlice.CanDeleteOwn);
|
||||||
|
|
||||||
|
// 谁能移除谁:所有者能移除除自己外的人;被授权的不能移除自己、所有者、同样被授权的人
|
||||||
|
var byOwner = env.Service.MembersView(ch.Id, owner).ToDictionary(v => v.Member.Username, v => v.CanRemove);
|
||||||
|
Assert.True(byOwner["alice"]); Assert.True(byOwner["carol"]);
|
||||||
|
Assert.False(byOwner[env.Service.GetChannel(ch.Id, owner).CreatorAccount!]);
|
||||||
|
var byAlice = env.Service.MembersView(ch.Id, alice).ToDictionary(v => v.Member.Username, v => v.CanRemove);
|
||||||
|
Assert.False(byAlice["alice"]); Assert.False(byAlice["bob"]); Assert.True(byAlice["carol"]);
|
||||||
|
Assert.All(env.Service.MembersView(ch.Id, carol), view => Assert.False(view.CanRemove)); // 没有移除权限
|
||||||
|
// 展示和执行用同一条规则:展示说不能移除的,真去移除也会被拒绝
|
||||||
|
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "bob"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ using WebDrop.Core;
|
|||||||
|
|
||||||
// Build-time reflection only; production serialization remains source-generated and AOT-safe.
|
// Build-time reflection only; production serialization remains source-generated and AOT-safe.
|
||||||
var root = Path.GetFullPath(args.FirstOrDefault(a => a != "--check") ?? ".");
|
var root = Path.GetFullPath(args.FirstOrDefault(a => a != "--check") ?? ".");
|
||||||
string[] sources = ["server/src/WebDrop.Api/ApiJson.cs", "server/src/WebDrop.Core/Dtos.cs", "server/src/WebDrop.Core/Settings.cs", "server/src/WebDrop.Core/Enums.cs", "server/src/WebDrop.Core/Workspace.cs", "server/src/WebDrop.Core/AccountProfile.cs", "server/src/WebDrop.Api/PasskeyDtos.cs", "server/src/WebDrop.Core/WorkspaceCrypto.cs", "server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs"];
|
string[] sources = ["server/src/WebDrop.Api/Json/ApiJson.cs", "server/src/WebDrop.Core/Channels/Dtos.cs", "server/src/WebDrop.Core/Channels/Settings.cs", "server/src/WebDrop.Core/Common/Enums.cs", "server/src/WebDrop.Core/Spaces/Workspace.cs", "server/src/WebDrop.Core/Accounts/AccountProfile.cs", "server/src/WebDrop.Api/Passkeys/PasskeyDtos.cs", "server/src/WebDrop.Core/Spaces/WorkspaceCrypto.cs", "server/src/WebDrop.Api/Endpoints/Endpoints.WorkspaceCrypto.cs"];
|
||||||
var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", sources.Select(p => p + "\n" + File.ReadAllText(Path.Combine(root, p)).Replace("\r\n", "\n") + "\n")))));
|
var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", sources.Select(p => p + "\n" + File.ReadAllText(Path.Combine(root, p)).Replace("\r\n", "\n") + "\n")))));
|
||||||
var pending = new SortedDictionary<string, Type>();
|
var pending = new SortedDictionary<string, Type>();
|
||||||
var done = new HashSet<Type>();
|
var done = new HashSet<Type>();
|
||||||
@@ -24,7 +24,8 @@ while (pending.Count > 0)
|
|||||||
if (type.IsEnum)
|
if (type.IsEnum)
|
||||||
{
|
{
|
||||||
var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) + "'");
|
var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) + "'");
|
||||||
output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};\n");
|
output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};");
|
||||||
|
output.AppendLine($"export const {type.Name} = {{ {string.Join(", ", type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + (f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) + "'"))} }} as const;\n");
|
||||||
definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) as JsonNode).ToArray()) };
|
definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) as JsonNode).ToArray()) };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -46,7 +47,20 @@ while (pending.Count > 0)
|
|||||||
output.AppendLine("}");
|
output.AppendLine("}");
|
||||||
definitions[type.Name] = new JsonObject { ["type"] = "object", ["properties"] = properties, ["required"] = required };
|
definitions[type.Name] = new JsonObject { ["type"] = "object", ["properties"] = properties, ["required"] = required };
|
||||||
}
|
}
|
||||||
var path = Path.Combine(root, "web/src/core-bridge/generated/api.ts");
|
// 不在 DTO 里、但前端要用的取值:错误码、令牌角色、空间权限、分享状态。一律由后端定义、生成常量
|
||||||
|
output.AppendLine("/** 错误响应里的 error 取值 */");
|
||||||
|
output.AppendLine($"export const ErrorCode = {{ {string.Join(", ", Enum.GetValues<ErrorCode>().Select(c => c + ": '" + c.ToWire() + "'"))} }} as const;");
|
||||||
|
output.AppendLine("export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];\n");
|
||||||
|
output.AppendLine("/** X-WD-Role 请求头:这次请求出示哪张令牌 */");
|
||||||
|
output.AppendLine($"export const TokenRole = {{ {string.Join(", ", Enum.GetValues<Role>().Where(r => r != Role.None).Select(r => r + ": '" + r.ToString().ToLowerInvariant() + "'"))} }} as const;");
|
||||||
|
output.AppendLine("export type TokenRole = (typeof TokenRole)[keyof typeof TokenRole];\n");
|
||||||
|
output.AppendLine("/** 空间权限(身份组授予) */");
|
||||||
|
output.AppendLine($"export const WorkspacePermission = {{ {string.Join(", ", typeof(WorkspacePermission).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy).Where(f => f.IsLiteral && f.FieldType == typeof(string) && f.Name != "Colors").Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;");
|
||||||
|
output.AppendLine("export type WorkspacePermission = (typeof WorkspacePermission)[keyof typeof WorkspacePermission];\n");
|
||||||
|
output.AppendLine("/** 便捷分享的状态 */");
|
||||||
|
output.AppendLine($"export const ShareState = {{ {string.Join(", ", typeof(ShareState).GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;");
|
||||||
|
output.AppendLine("export type ShareState = (typeof ShareState)[keyof typeof ShareState];");
|
||||||
|
var path = Path.Combine(root, "web/src/api/generated.ts");
|
||||||
var text = output.ToString().Replace("\r\n", "\n");
|
var text = output.ToString().Replace("\r\n", "\n");
|
||||||
if (args.Contains("--check"))
|
if (args.Contains("--check"))
|
||||||
{
|
{
|
||||||
|
|||||||
Loaded 100 of 361 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user