remove fcm official
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
branches-ignore:
|
||||
- "main"
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- build: linux
|
||||
os: ubuntu-latest
|
||||
rust: stable
|
||||
target: x86_64-unknown-linux-musl
|
||||
pre_install: sudo apt-get install -y rsync
|
||||
- build: macos
|
||||
os: macos-latest
|
||||
rust: stable
|
||||
target: x86_64-apple-darwin
|
||||
pre_install: brew install rsync
|
||||
# - build: macos-arm
|
||||
# os: macos-latest
|
||||
# rust: stable
|
||||
# target: aarch64-apple-darwin
|
||||
# cross: true
|
||||
# pre_install: brew install rsync
|
||||
- build: arm-v7
|
||||
os: ubuntu-latest
|
||||
rust: stable
|
||||
target: armv7-unknown-linux-musleabihf
|
||||
cross: true
|
||||
pre_install: sudo apt-get install -y rsync
|
||||
- build: aarch64
|
||||
os: ubuntu-latest
|
||||
rust: stable
|
||||
target: aarch64-unknown-linux-musl
|
||||
cross: true
|
||||
pre_install: sudo apt-get install -y rsync
|
||||
steps:
|
||||
- name: Install SSH Key
|
||||
uses: shimataro/ssh-key-action@v2
|
||||
with:
|
||||
key: ${{ secrets.NEW_GEN_ACTIONS_PRIVATE_KEY }}
|
||||
known_hosts: ${{ secrets.VOCECHAT_COM_HOST }}
|
||||
- name: Adding Known Hosts
|
||||
run: ssh-keyscan -H "${{ secrets.VOCECHAT_COM_HOST }}" >> ~/.ssh/known_hosts
|
||||
- name: Install rsync
|
||||
run: ${{ matrix.pre_install }}
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
use-cross: true
|
||||
command: build
|
||||
args: --release --target ${{ matrix.target }}
|
||||
- run: |
|
||||
cp target/${{ matrix.target }}/release/vocechat-server ./vocechat-server
|
||||
zip -r ./vocechat-server-${{ github.ref_name}}-${{ matrix.target }}.zip ./vocechat-server config
|
||||
- name: Set AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v1
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
- if: matrix.target == 'x86_64-unknown-linux-musl'
|
||||
run: |
|
||||
docker -v
|
||||
cp -rf target/${{ matrix.target }}/release/vocechat-server build/docker/
|
||||
cd build/docker
|
||||
cp -rf ../../config ./
|
||||
docker login -u privoce -p ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# build latest
|
||||
docker build -t vocechat-server:latest .
|
||||
docker tag vocechat-server:latest privoce/vocechat-server:latest
|
||||
docker push privoce/vocechat-server:latest
|
||||
|
||||
# build version
|
||||
docker build -t vocechat-server:${{ github.ref_name}} .
|
||||
docker tag vocechat-server:${{ github.ref_name}} privoce/vocechat-server:${{ github.ref_name}}
|
||||
docker push privoce/vocechat-server:${{ github.ref_name}}
|
||||
|
||||
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 384878476649.dkr.ecr.us-east-1.amazonaws.com
|
||||
docker build --platform linux/amd64 -t 384878476649.dkr.ecr.us-east-1.amazonaws.com/vocechat-server:latest .
|
||||
docker push 384878476649.dkr.ecr.us-east-1.amazonaws.com/vocechat-server:latest
|
||||
|
||||
docker build --platform linux/amd64 -t 384878476649.dkr.ecr.us-east-1.amazonaws.com/vocechat-server:${{ github.ref_name}} .
|
||||
docker push 384878476649.dkr.ecr.us-east-1.amazonaws.com/vocechat-server:${{ github.ref_name}}
|
||||
- if: matrix.target == 'aarch64-unknown-linux-musl'
|
||||
run: |
|
||||
docker -v
|
||||
cp -rf target/${{ matrix.target }}/release/vocechat-server build/docker/
|
||||
cd build/docker
|
||||
cp -rf ../../config ./
|
||||
docker login -u privoce -p ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# build latest
|
||||
sudo apt install -y qemu-user-static binfmt-support
|
||||
sed -i "s/alpine/arm64v8\/alpine/ig" Dockerfile
|
||||
sed -i "s/busybox/arm64v8\/busybox/ig" Dockerfile
|
||||
docker buildx build -t vocechat-server:latest-arm64 --platform linux/arm64 .
|
||||
docker tag vocechat-server:latest-arm64 privoce/vocechat-server:latest-arm64
|
||||
docker push privoce/vocechat-server:latest-arm64
|
||||
|
||||
# build version
|
||||
docker buildx build -t vocechat-server:${{ github.ref_name}}-arm64 --platform linux/arm64 .
|
||||
docker tag vocechat-server:${{ github.ref_name}}-arm64 privoce/vocechat-server:${{ github.ref_name}}-arm64
|
||||
docker push privoce/vocechat-server:${{ github.ref_name}}-arm64
|
||||
- name: rsync to sh.voce.chat
|
||||
run: |
|
||||
rsync -av vocechat-server-${{ github.ref_name}}-${{ matrix.target }}.zip root@${{ secrets.VOCECHAT_COM_HOST }}:/home/wwwroot/sh.voce.chat/vocechat-server-${{ github.ref_name}}-${{ matrix.target }}.zip
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: vocechat-server-${{ github.ref_name}}-${{ matrix.target }}
|
||||
path: vocechat-server-${{ github.ref_name}}-${{ matrix.target }}.zip
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
# - uses: dev-drprasad/delete-tag-and-release@v0.2.0
|
||||
# with:
|
||||
# delete_release: true
|
||||
# tag_name: ${{ github.ref_name }}
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.THE_GITHUB_TOKEN }}
|
||||
- uses: actions/checkout@v3
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.THE_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
release_name: Release ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
add:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, release]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- build: linux
|
||||
os: ubuntu-latest
|
||||
rust: stable
|
||||
target: x86_64-unknown-linux-musl
|
||||
- build: macos
|
||||
os: macos-latest
|
||||
rust: stable
|
||||
target: x86_64-apple-darwin
|
||||
# - build: macos-arm
|
||||
# os: macos-latest
|
||||
# rust: stable
|
||||
# target: aarch64-apple-darwin
|
||||
# cross: true
|
||||
# pre_install: brew install rsync
|
||||
- build: arm-v7
|
||||
os: ubuntu-latest
|
||||
rust: stable
|
||||
target: armv7-unknown-linux-musleabihf
|
||||
cross: true
|
||||
- build: aarch64
|
||||
os: ubuntu-latest
|
||||
rust: stable
|
||||
target: aarch64-unknown-linux-musl
|
||||
cross: true
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: vocechat-server-${{ github.ref_name}}-${{ matrix.target }}
|
||||
- name: Upload Artifact to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.THE_GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
asset_path: ./vocechat-server-${{ github.ref_name}}-${{ matrix.target }}.zip
|
||||
asset_name: vocechat-server-${{ github.ref_name}}-${{ matrix.target }}.zip
|
||||
asset_content_type: application/zip
|
||||
@@ -0,0 +1,8 @@
|
||||
target
|
||||
/data
|
||||
.idea
|
||||
build/docker/config
|
||||
build/docker/wwwroot
|
||||
build/docker/*.zip
|
||||
build/docker/vocechat-server
|
||||
wwwroot
|
||||
@@ -0,0 +1,12 @@
|
||||
edition = "2021"
|
||||
newline_style = "unix"
|
||||
# comments
|
||||
normalize_comments = true
|
||||
wrap_comments = true
|
||||
format_code_in_doc_comments = true
|
||||
# imports
|
||||
imports_granularity = "Crate"
|
||||
group_imports = "StdExternalCrate"
|
||||
# report
|
||||
#report_fixme = "Unnumbered"
|
||||
#report_todo = "Unnumbered"
|
||||
Generated
+5133
File diff suppressed because it is too large
Load Diff
+116
@@ -0,0 +1,116 @@
|
||||
[package]
|
||||
name = "vocechat-server"
|
||||
version = "0.3.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
rc-msgdb = { path = "./crates/msgdb" }
|
||||
rc-token = { path = "./crates/token" }
|
||||
rc-fcm = { path = "./crates/fcm" }
|
||||
rc-magic-link = { path = "./crates/magic-link" }
|
||||
vc-license = { path = "./crates/vc-license" }
|
||||
agora-token = { path = "./crates/agora-token" }
|
||||
open-graph = { path = "./crates/open-graph", features = ["poem_openapi"] }
|
||||
|
||||
poem = { version = "1.3.51", features = [
|
||||
"rustls",
|
||||
"sse",
|
||||
"static-files",
|
||||
"test",
|
||||
"i18n",
|
||||
"anyhow",
|
||||
"acme",
|
||||
'tokio-metrics',
|
||||
] }
|
||||
poem-openapi = { version = "2.0.23", features = [
|
||||
"rapidoc",
|
||||
"swagger-ui",
|
||||
"redoc",
|
||||
"chrono",
|
||||
"email",
|
||||
"static-files",
|
||||
] }
|
||||
serde = { version = "1.0.132", features = ["derive"] }
|
||||
tokio = { version = "1.17.0", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"sync",
|
||||
"fs",
|
||||
"time",
|
||||
] }
|
||||
toml = "0.5.8"
|
||||
tracing = "0.1.29"
|
||||
tracing-subscriber = { version = "0.3.3", features = ["env-filter"] }
|
||||
serde_json = "1.0.73"
|
||||
parking_lot = "0.11.2"
|
||||
sqlx = { version = "0.5.9", features = [
|
||||
"sqlite",
|
||||
"runtime-tokio-rustls",
|
||||
"chrono",
|
||||
"json",
|
||||
] }
|
||||
itertools = "0.10.3"
|
||||
futures-util = "0.3.19"
|
||||
async-stream = "0.3.2"
|
||||
image = "0.24.2"
|
||||
uuid = { version = "0.8.2", features = ["v4"] }
|
||||
rcgen = { version = "0.8.14", features = ["x509-parser"] }
|
||||
tokio-stream = "0.1.8"
|
||||
num_enum = "0.5.5"
|
||||
unic-langid = "0.9.0"
|
||||
reqwest = { version = "0.11.8", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"json",
|
||||
] }
|
||||
lettre = { version = "0.10.0-rc.4", default-features = false, features = [
|
||||
"smtp-transport",
|
||||
"hostname",
|
||||
"builder",
|
||||
"tokio1",
|
||||
"tokio1-rustls-tls",
|
||||
] }
|
||||
serde_urlencoded = "0.7.1"
|
||||
liquid = "0.23.1"
|
||||
fastrand = "1.7.0"
|
||||
fs2 = "0.4.3"
|
||||
hmac = "0.11.0"
|
||||
sha2 = "0.9.0"
|
||||
substring = "1.4.5"
|
||||
regex = "1.5.4"
|
||||
jsonwebtoken = "8.0.1"
|
||||
openidconnect = "2.4.0"
|
||||
#openidconnect = { git = "https://github.com/sunli829/openidconnect-rs", rev = "efd006c" }
|
||||
bytes = "1.1.0"
|
||||
textnonce = "1.0.0"
|
||||
web3 = { version = "0.18.0", default-features = false, features = ["signing"] }
|
||||
clap = { version = "3.1.6", features = ["derive"] }
|
||||
walkdir = "2.3.2"
|
||||
base64 = "0.13.0"
|
||||
zip = { version = "0.6.0", default-features = false, features = ["deflate"] }
|
||||
mime_guess = "2.0.4"
|
||||
url = "1.7.2"
|
||||
once_cell = "1.13.0"
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
anyhow = "1.0.51"
|
||||
hex = "0.4.3"
|
||||
envy = "0.4.2"
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
daemonize = "0.4.1"
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/msgdb",
|
||||
"crates/token",
|
||||
"crates/fcm",
|
||||
"crates/open-graph",
|
||||
"crates/vc-license",
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.11.8", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
] }
|
||||
tempfile = "3.2.0"
|
||||
fnv = "1.0.7"
|
||||
@@ -0,0 +1,81 @@
|
||||
|
||||
# Re-decentralized the Internet through personal cloud computing.
|
||||
## VoceChat is the `lightest` chat server prioritizes private hosting! Easy integratation to your app with our open API!
|
||||
### Quick run
|
||||
|
||||
```bash
|
||||
docker run -d --restart=always \
|
||||
-p 3009:3000 \
|
||||
--name vocechat-server \
|
||||
Privoce/vocechat-server:latest
|
||||
```
|
||||
|
||||
On browser visit: http://localhost:3009/
|
||||
|
||||
|
||||
### Contact us
|
||||
|
||||
Doc: [https://doc.voce.chat](https://doc.voce.chat)
|
||||
Chat with us: [https://voce.chat](https://voce.chat)
|
||||
Email: [han@privoce.com](han@privoce.com)
|
||||
### Welcome your help of any form--code, issues, blog articles, social media shares.
|
||||
|
||||
# Goal
|
||||
|
||||
The goal of Voce is to provide a space owned by "you" on the Internet, just as the name "voce" means in portguese. As cloud computing getting matured, there will be a time in the foreseeable future that netizens like you and me can enjoy the benefit of having a personal cloud space on the Internet--like iCloud, while not only will you have storage, but also a personalized computing layer.
|
||||
|
||||
Why do we need a private cloud space? The short answer is that information will become our private property and means of communication will be under the control of every netizen.
|
||||
|
||||
Digital technologies including Internet and the Web make information duplicable and transferable, yet platforms are controlling the ownership and means of production and transmission of most information (content) on the Internet. We are building Voce as an open-sourced tool (not a platform) to empower smaller groups and individuals to become a platform themselves. When various servers get connected to each other, all the information shared among the servers will be duplicated and become the server owner's private data under private storages.
|
||||
|
||||
# Personal cloud
|
||||
|
||||
We need a "home" as our private property on the Internet, and this home should have features more than just storage. No matter you choose NAS, AWS, Raspberry Pi or your local PC to run VoceChat, it is your own private property--yes, public cloud services like AWS purchased by you is commercially your own property and the data in your cloud is protected from accessing by the public cloud service providers. Features including but not limited to instant messaging, activity posts, private video calls, notes, whiteboard are all desiring and useful on your personal cloud server. Personal cloud is the new PC, and there will be a new software layer with some shared traits--distributed by URL(web app), good API, interoperable profiles. Privoce is currently working on this software layer of personal cloud.
|
||||
|
||||
# Why Rust
|
||||
|
||||
Personal cloud needs efficent solutions. This Rust written server is less than 20MB and runs with great efficiency. Hosting a 20MB private social media server is much more accessible than a 300MB one. Just like cars v. bus, or single family home v. hotels, if Facebook is a grand hotel, VoceChat is like a house. If Matrix and Mastodon are buses, VoceChat is like a car. The next paradigm of the internet is serverless functions + personal storage. Making VoceChat serverless is also a next step.
|
||||
|
||||
# Webhook, bot, and ways to get interconnected (work in progress)
|
||||
|
||||
Social media cannot always stay private if people want new information to be transferred among different servers. The word "social" in social media means multiple nodes communicate with multiple nodes in a synced space (mass to mass). A channel (private or public) should serve the end of this mass to mass data sharing and syncing space. Channel should function as a shared good where members who joined should have the right to store the same chat data (use their server)--this may one day functions like Bitcoin, yet currently, efficiency should be more prioritiezed and admin of the channel could have the right to assign different "power" to members. We have implemented both inbound and outbound webhooks so that it's possible to sync messages from a VoceChat channel to another channel (slack, discord, or VoceChat) though you will need to write your own server side code to personalize this process (this could be troublesome, and again, serverless could be a good solution).
|
||||
|
||||
We also found bot super interesting and personal bot services may replace platforms--e.g., we are training some bots based on GPT api that can be added to VoceChat.
|
||||
|
||||
If you have a proposal on how multiple servers could get interconnected, e.g., should we support Matrix protocal, or other protocal, feel free to share your thougts in the discussions, or directly discuss it with at our chat at https://voce.chat.
|
||||
|
||||
# Free for personal use, require license for non-personal use.
|
||||
|
||||
`VoceChat` is an open-source commercial software. The VoceChat server is the smallest, stablest and most efficient independent chat server on today's market, which is good for integration to your own app. VoceChat official image is free for personal use, which we define as equal or less than 20 registered users, if you want to integrate VoceChat to your own app/site for a larger user base, you have to purchase a license. The license is 49$/version.
|
||||
|
||||
Our team also provide customization service. We also provide resale license for NAS and cloud providers who want to collaborate with us.
|
||||
|
||||
### Project composition:
|
||||
|
||||
| Name | Tech | Project | License | Comment |
|
||||
| ---------- | ----------------------------------- | --------------------------------------------------------- | ------- | --------------------------------------------------- |
|
||||
| Server | Rust | [vocechat-server](https://github.com/privoce/vocechat-server-rust) | Big Time Public License | Server supports Linux, Windows, ARM32/64 |
|
||||
| Server image | Docker, Shell | [vocechat-docker](https://hub.docker.com/r/privoce/vocechat-server/tags) | Creative Commons Attribution-NonCommercial 4.0 International | Official image supports Linux, ARM64 |
|
||||
| APP Client | Flutter | [vocechat-client](https://github.com/privoce/vocechat-client) | GPLv3 | Client supports Android and IOS platforms |
|
||||
| Web Client | React | [vocechat-web](https://github.com/privoce/vocechat-web) | GPLv3 | Web App, integrated management |
|
||||
| Documentation | [docusaurus](https://docusaurus.io) | [vocechat-doc](https://github.com/privoce/vocechat-doc) | GPLv3 | Vocechat document website |
|
||||
|
||||
### Feature list & roadmap
|
||||
|
||||
- [x] DM & Group Chating / 2021-Q4
|
||||
- [x] Reply, @ to mention a person / 2021-Q4
|
||||
- [x] Images and large files transmission / 2021-Q4
|
||||
- [x] Pin / 2022-Q1
|
||||
- [x] Forward / 2022-Q1
|
||||
- [x] Favorate / 2022-Q1
|
||||
- [x] Auto-delete my messages / 2022-Q2
|
||||
- [x] Voice (now support agora) / 2022-Q4
|
||||
- [x] Video (now support agora)/ 2022-Q4
|
||||
- [x] Bot and Webhook (inbound and outbound)/ 2022-Q4
|
||||
- [ ] Role based permission control/ Undecided
|
||||
- [ ] Post, based on ActivityPub/ Undecided
|
||||
- [ ] Matrix Bridge/ Undecided
|
||||
|
||||
|
||||
Chat with us: https://voce.chat
|
||||
Email: han@privoce.com
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM alpine as certs
|
||||
RUN apk update && apk add ca-certificates
|
||||
|
||||
FROM busybox
|
||||
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
|
||||
COPY vocechat-server /home/vocechat-server/vocechat-server
|
||||
COPY config /home/vocechat-server/config
|
||||
COPY docker-entrypoint.sh /
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
ENV VOCECHAT_FCM_PROJECT_ID=""\
|
||||
VOCECHAT_FCM_PRIVATE_KEY=""\
|
||||
VOCECHAT_FCM_CLIENT_EMAIL=""\
|
||||
VOCECHAT_FCM_TOKEN_URI="https://oauth2.googleapis.com/token"
|
||||
EXPOSE 3000
|
||||
WORKDIR /home/vocechat-server
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd ../../
|
||||
|
||||
#alias rust-musl-builder='docker run --rm -it -v "$(pwd)":/home/rust/src ekidd/rust-musl-builder'
|
||||
#rust-musl-builder cargo build --release
|
||||
docker run --rm -it -v "$(pwd)":/home/rust/src ekidd/rust-musl-builder cargo build --release
|
||||
|
||||
if [ -f ./target/x86_64-unknown-linux-musl/release/vocechat-server ]; then
|
||||
|
||||
cp -rf ./target/x86_64-unknown-linux-musl/release/vocechat-server build/docker/vocechat-server
|
||||
cp -rf config build/docker/
|
||||
|
||||
cd build/docker
|
||||
|
||||
# sudo docker login -u vocechat -p xxxxxx
|
||||
# build arm64
|
||||
sudo docker build --platform=linux/amd64 -t vocechat-server:latest .
|
||||
docker tag vocechat-server:latest Privoce/vocechat-server:latest
|
||||
docker push Privoce/vocechat-server:latest
|
||||
|
||||
# build arrch64
|
||||
cp -rf ./target/aarch64-unknown-linux-musl/release/vocechat-server build/docker/vocechat-server
|
||||
sudo docker build --platform=linux/arm64 -t vocechat-server:latest-arm64 .
|
||||
docker tag vocechat-server:latest-arm64 Privoce/vocechat-server:latest-arm64
|
||||
docker push Privoce/vocechat-server:latest-arm64
|
||||
#rm -rf ./config
|
||||
|
||||
fi
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
mkdir -p ~/.vocechat-server/data/cert
|
||||
docker stop vocechat-server
|
||||
docker rm vocechat-server
|
||||
docker pull Privoce/vocechat-server:latest
|
||||
docker run -d --restart=always \
|
||||
-p 443:443 \
|
||||
--name vocechat-server \
|
||||
-v ~/.vocechat-server/data:/home/vocechat-server/data \
|
||||
Privoce/vocechat-server:latest \
|
||||
--network.bind "0.0.0.0:443" \
|
||||
--network.domain "chat.domain.com" \
|
||||
--network.tls.type "acme_tls_alpn_01" \
|
||||
--network.tls.acme.directory_url "https://acme-v02.api.letsencrypt.org/directory" \
|
||||
--network.tls.acme.cache_path "/home/vocechat-server/data/cert"
|
||||
docker logs -f vocechat-server
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3'
|
||||
services:
|
||||
vocechat:
|
||||
image: vocechat-server:latest
|
||||
container_name: vocechat-server
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command:
|
||||
"/docker-entrypoint.sh"
|
||||
- "--network.bind"
|
||||
- "0.0.0.0:3000"
|
||||
- "--network.domain"
|
||||
- "localhost"
|
||||
# environment:
|
||||
# - UID=1000
|
||||
# - GID=1000
|
||||
# - ADMIN_USER=admin
|
||||
# - ADMIN_PASSWORD=123456
|
||||
# - DOMAIN=localhost
|
||||
volumes:
|
||||
- /share/data/docker/vocechat-server/data:/home/vocechat-server/data
|
||||
expose:
|
||||
- 3000
|
||||
ports:
|
||||
- 3000:3000/tcp
|
||||
restart: always
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
|
||||
#uid=${UID:=$(id -u)}
|
||||
#gid=${GID:=$(id -g)}
|
||||
#admin_user=${ADMIN_USER:="admin"}
|
||||
#admin_password=${ADMIN_PASSWORD:="123456"}
|
||||
#domain=${DOMAIN:=""}
|
||||
#
|
||||
#if test ! -z $domain; then
|
||||
# sed -i "s/# domain = .*\$/domain = \"$domain\"/ig" /home/vocechat-server/config/config.toml
|
||||
#fi
|
||||
#
|
||||
#if grep -q "name = \"$admin_user\"" /home/vocechat-server/config/config.toml; then
|
||||
#cat <<EOT >> /home/vocechat-server/config/config.toml
|
||||
#[[user]]
|
||||
#name = "$admin_user"
|
||||
#password = "$admin_password"
|
||||
#email = "$admin_user@$domain"
|
||||
#is_admin = true
|
||||
#EOT
|
||||
#fi
|
||||
|
||||
cd /home/vocechat-server/
|
||||
cmd="./vocechat-server"
|
||||
if test $# -gt 0; then
|
||||
case "$1" in
|
||||
*.toml)
|
||||
cmd="./vocechat-server $@"
|
||||
break
|
||||
;;
|
||||
--*)
|
||||
cmd="./vocechat-server $@"
|
||||
break
|
||||
;;
|
||||
*) cmd="$@";;
|
||||
esac
|
||||
fi
|
||||
echo $cmd
|
||||
$cmd
|
||||
# exec "$cmd"
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/bin/sh
|
||||
# auto-check version requires repo to be public
|
||||
# VOCECHAT_SERVER_VERION=`curl -s https://github.com/Privoce/vocechat-server/releases/latest | sed "s/.*tag\/\(.*\)\".*/\1/ig"`
|
||||
VOCECHAT_SERVER_VERION="v0.3.1"
|
||||
ARCH=`uname -m`
|
||||
OS=`uname`
|
||||
PLATFORM="x86_64-unknown-linux-musl"
|
||||
WORK_DIR=""
|
||||
BIND_PORT=3000
|
||||
DOMAIN=""
|
||||
HTTPS_ON=""
|
||||
#PWD=`pwd`
|
||||
|
||||
echo " ┌────────────────────────────────────────────────────────────────┐ "
|
||||
echo " │ vocechat-server $VOCECHAT_SERVER_VERION installation guide │ "
|
||||
echo " └────────────────────────────────────────────────────────────────┘ "
|
||||
|
||||
x_read() {
|
||||
read -r $1 < /dev/tty
|
||||
}
|
||||
|
||||
|
||||
|
||||
have_command() {
|
||||
if command -v $1 >/dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_env() {
|
||||
if test "$OS" != "Darwin"; then
|
||||
if have_command apt; then
|
||||
apt install -y unzip
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
error_exit() {
|
||||
echo -e "\033[31m$1\033[0m"
|
||||
exit
|
||||
}
|
||||
|
||||
sed_replace() {
|
||||
if test "$OS" = "Darwin" ; then
|
||||
sed -i "" $*
|
||||
else
|
||||
sed -i $*
|
||||
fi
|
||||
}
|
||||
random_64() {
|
||||
for i in {1..64}
|
||||
do
|
||||
echo $(( RANDOM % 10 )) | xargs echo -n
|
||||
done
|
||||
}
|
||||
is_writable()
|
||||
{
|
||||
if test ! -d $1; then
|
||||
mkdir -p $1;
|
||||
fi
|
||||
if test -w $1; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
port_in_use()
|
||||
{
|
||||
n=`netstat -nlpt|grep ":$1 "|wc -l`
|
||||
if test $n -gt 0; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
input_writable_dir()
|
||||
{
|
||||
WORK_DIR=""
|
||||
for workdir in /share/Download ~ `pwd`; do
|
||||
if test -d $workdir; then
|
||||
workdir2="$workdir/.vocechat-server"
|
||||
if is_writable $workdir2; then
|
||||
WORK_DIR=$workdir2;
|
||||
break;
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo -e "Installation path (Default: \033[31m$WORK_DIR\033[0m):"
|
||||
while true; do
|
||||
x_read workdir
|
||||
if test "$workdir" != ""; then
|
||||
WORK_DIR=$workdir
|
||||
fi
|
||||
if test ! -w $WORK_DIR; then
|
||||
echo "$WORK_DIR is unwritable! "
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
input_domain()
|
||||
{
|
||||
echo "Please input domain (Default empty):"
|
||||
x_read DOMAIN
|
||||
if test "$DOMAIN" != ""; then
|
||||
input_https_on
|
||||
fi
|
||||
}
|
||||
|
||||
input_https_on()
|
||||
{
|
||||
echo "Enable HTTPS: [y,n] (Default n)"
|
||||
x_read HTTPS_ON
|
||||
HTTPS_ON=$(echo $HTTPS_ON|tr [A-Z] [a-z])
|
||||
if test "$HTTPS_ON" = "y"; then
|
||||
if port_in_use 443; then
|
||||
error_exit "Port 443 is in use! Enable SSL requires opening port 443."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
install_as_service()
|
||||
{
|
||||
echo "Start with system launched: [y,n] (Default: n)"
|
||||
x_read AS_SERVICE
|
||||
if test "$AS_SERVICE" = "y"; then
|
||||
if test "$OS" = "Darwin"; then
|
||||
cat >> ~/Library/LaunchAgents/com.vocechat.server.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.vocechat.server.plist</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>bin/vocechat-server</string>
|
||||
<string>config/config.toml</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
check_env
|
||||
|
||||
input_writable_dir
|
||||
|
||||
cd $WORK_DIR
|
||||
if test -d $WORK_DIR/data/db; then
|
||||
rm -rf $WORK_DIR/data
|
||||
fi
|
||||
|
||||
input_domain
|
||||
|
||||
|
||||
case $ARCH in
|
||||
arm64)
|
||||
if test `uname` = "Darwin"; then
|
||||
PLATFORM="aarch64-apple-darwin"
|
||||
else
|
||||
PLATFORM="aarch64-unknown-linux-musl"
|
||||
fi
|
||||
;;
|
||||
aarch64)
|
||||
PLATFORM="aarch64-unknown-linux-musl"
|
||||
;;
|
||||
armv7l | arm)
|
||||
PLATFORM="armv7-unknown-linux-musleabihf"
|
||||
;;
|
||||
x86_64)
|
||||
if test `uname` = "Darwin"; then
|
||||
PLATFORM="x86_64-apple-darwin"
|
||||
else
|
||||
PLATFORM="x86_64-unknown-linux-musl"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo -n "error: not supportted arch $(ARCH)!"
|
||||
exit
|
||||
;;
|
||||
esac
|
||||
|
||||
echo -e "Detected platform: \033[31m$PLATFORM\033[0m."
|
||||
|
||||
BIN_NAME="vocechat-server-$VOCECHAT_SERVER_VERION-$PLATFORM.zip"
|
||||
BIN_URL="https://s.voce.chat/$BIN_NAME"
|
||||
echo "Downloading URL: $BIN_URL"
|
||||
|
||||
# clear old data:
|
||||
kill `pidof vocechat-server` 2>/dev/null
|
||||
if test ! -f vocechat-server.zip; then
|
||||
curl --progress-bar -f $BIN_URL -o vocechat-server.zip || exit
|
||||
fi
|
||||
unzip -oq vocechat-server.zip || exit
|
||||
chmod a+x vocechat-server
|
||||
|
||||
|
||||
curl -f "https://s.voce.chat/vocechat-server.sh" -o vocechat-server.sh || exit
|
||||
if test "$DOMAIN" != ""; then
|
||||
sed -i "s/# domain = .*\$/domain = \"$DOMAIN\"/ig" config/config.toml
|
||||
echo $HTTPS_ON
|
||||
if test "$HTTPS_ON" = "y"; then
|
||||
BIND_PORT=443
|
||||
sed -i "s/bind = .*\$/bind = \"0.0.0.0:$BIND_PORT\"/ig" config/config.toml
|
||||
sed -i ':a;N;$!ba;s/# \[network\.tls\]/[network.tls]/4' config/config.toml
|
||||
sed -i ':a;N;$!ba;s/# type = \"acme_tls_alpn_01\"/type = "acme_tls_alpn_01"/1' config/config.toml
|
||||
sed -i ':a;N;$!ba;s/# cache_path/cache_path/2' config/config.toml
|
||||
mkdir -p $WORK_DIR/data/cert
|
||||
# tr '\n' '^' < config/config.toml | sed 's/# \[network\.tls\]/[network.tls]/4' | tr '^' '\n' > config/config2.toml
|
||||
fi
|
||||
fi
|
||||
|
||||
sed_replace "s#WORKDIR=.*\$#WORKDIR=\"$WORK_DIR\"#ig" vocechat-server.sh
|
||||
|
||||
chmod a+x vocechat-server.sh
|
||||
|
||||
if test -d /etc/init.d/; then
|
||||
cp -rf vocechat-server.sh /etc/init.d/
|
||||
echo "install done! "
|
||||
echo "run: /etc/init.d/vocechat-server.sh start|stop|restart"
|
||||
else
|
||||
echo "/etc/init.d/ does not exists, refused! please try Ubuntu or CentOS, you can contact han@privoce.com."
|
||||
exit
|
||||
# echo "run: /etc/init.d/vocechat-server.sh start|stop|restart"
|
||||
fi
|
||||
|
||||
export PATH=$PATH:$WORK_DIR
|
||||
echo "export PATH=\$PATH:$WORK_DIR" >> /etc/profile
|
||||
#cd $PWD
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
START=90
|
||||
STOP=10
|
||||
RETVAL=0
|
||||
|
||||
PWD=`pwd`
|
||||
WORKDIR=""
|
||||
|
||||
cd $WORKDIR
|
||||
mkdir data/wwwroot
|
||||
case "$1" in
|
||||
start)
|
||||
echo -n "Starting vocechat-server:"
|
||||
./vocechat-server config/config.toml --daemon --stdout=data/vocechat-server.log 2>&1
|
||||
echo -e "\033[32m [OK] \033[0m"
|
||||
;;
|
||||
stop)
|
||||
echo -n "Shutting down vocechat-server:"
|
||||
kill `pidof vocechat-server` 2>&1
|
||||
echo -e "\033[32m [OK] \033[0m"
|
||||
RETVAL=$?
|
||||
;;
|
||||
restart)
|
||||
$0 stop
|
||||
$0 start
|
||||
RETVAL=$?
|
||||
;;
|
||||
log)
|
||||
tail -f $WORKDIR/data/vocechat-server.log
|
||||
;;
|
||||
*)
|
||||
echo "Usage $0 {start|stop|restart}"
|
||||
cd $PWD
|
||||
exit 1
|
||||
esac
|
||||
|
||||
cd $PWD
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFhjCCA24CCQCCJqbypeaQ3zANBgkqhkiG9w0BAQsFADCBhDELMAkGA1UEBhMC
|
||||
Q04xEDAOBgNVBAgMB0JlaWppbmcxEDAOBgNVBAcMB0JlaWppbmcxETAPBgNVBAoM
|
||||
CFJ1c3RjaGF0MRswGQYDVQQDDBJSdXN0Y2hhdCBTZXJ2ZXIgQ0ExITAfBgkqhkiG
|
||||
9w0BCQEWEmFkbWluQHJ1c3RjaGF0LmNvbTAeFw0yMTEyMjkwNTE2NDhaFw0zMTEy
|
||||
MjcwNTE2NDhaMIGEMQswCQYDVQQGEwJDTjEQMA4GA1UECAwHQmVpamluZzEQMA4G
|
||||
A1UEBwwHQmVpamluZzERMA8GA1UECgwIUnVzdGNoYXQxGzAZBgNVBAMMElJ1c3Rj
|
||||
aGF0IFNlcnZlciBDQTEhMB8GCSqGSIb3DQEJARYSYWRtaW5AcnVzdGNoYXQuY29t
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4mGgT49d1jwhZIp/ezop
|
||||
1o+kJChww+roIYVBL1J71cqWd6E+w9KeSE9UT3HYsXvM9q2XwcFjyZCT0eko3evW
|
||||
E8Tt/C8jlO/gjhAiNQu20RtPMELwLnexq3+3zpg8lAVywtAwTJk29/uP3/13jraR
|
||||
PsbRqlydFyikQwlswV6nCTwUXTN3IjJdf/Ej58G+D4+CDzBpsA1hlzt5q0dGOVRK
|
||||
1zUvwJ0Urz48unDGVHSl6rgsww6v7H2GCiCspRSdXBvrqJKM+bJoU0R+ITRD90Po
|
||||
z725NBcdxYtOP1NBbNnihqjvjagvn1ODy1bGtkGBUjRuBEjSn2A8UxHO5999lZQ4
|
||||
vLWPa5YI9Kt6GQ23lx9NE11DsAnzvfVtJ3Sr5J1F7LgX8+XNYUH5LrM27/eFgOSX
|
||||
HMbv3FNJ8kbaHuAlXxhEQFTub7qIwF++U+kyKwZUMWHa392Dq1ejI33cOkfx5HdS
|
||||
fPGcvmskE0R+dE+fI/g/t05i5ayp3csaLJlcgu0IFdO4coKhPte1+Zq1OEd/Kh+w
|
||||
/DqETlyR0Ga+kmzE+Y02yQTXvIlzJZ/CYdbz8dC27KAXMCI9A1ra6oHs+VhvMmrn
|
||||
MIk96uWEbir0RdJcDrZgnAJmh1/wkPPbS+R2Qwbj70qOpFCDg0B9r7ydhybbP0op
|
||||
REvvOBKaDdDMtw5bEN1/wZUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAkSdY7ttI
|
||||
4Fj6tUKV8447fX2XRBO5GZo2ZtuB+udE3ioRkO/KOrvaSm0GwqceFKnmebk5OR2P
|
||||
yZNC3caZ9/Z+M0ebkA1XlK6tsdVCY8jhpct2gg9yRlMdOMIkvhkLaXCBejy8+FDO
|
||||
ito5Aq67WB8jUfpWl2vw51oDVGdJjjt08AIxXzcYzngGPGxvonh9aD5oMoaqz5ak
|
||||
2WR7FwUkYCMT9KMAd4orSYykBdJMfu/AfTooTOMrztdUkznC46L3Wlr6+sJgWW+Q
|
||||
nnxn8drqeHDLRk/aFvRjJa3nOquqBwLSGgNJfiUfiMKfzswPvujhGSeQushe7Gbu
|
||||
9c8Jho5ELgAqqfx0EAKqsgx3/Pq4o1vgmaHHE/eOpMdrpRaCHg403Z0kAr1cAsgQ
|
||||
4puOEdt2mWVA0Lle5NRwkL/j5zceBQr7PLtEN9RuxpaQQI7xo3Lf+lD61PdL385F
|
||||
zQlDygoXtnrlBCSHEat49erGov15uqf1S8yF4rR375jwW59JJGHdaL8SLoI/msIg
|
||||
aD0nWUzS2TPD/FrniI58uHo+zuxC2spAigFeI6vkqRQCKVrWF7002Q6e+eukIySN
|
||||
eVY8Ilq8+4+wF1lTf2qSkbRkHZDt6SMyP8w7bOUgZ0pIJEfYcQWtC6WreCQ4GyjI
|
||||
CxSyHWv+MGOb/MlIx7XDW/GtErMHLpGgPvE=
|
||||
-----END CERTIFICATE-----
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDiYaBPj13WPCFk
|
||||
in97OinWj6QkKHDD6ughhUEvUnvVypZ3oT7D0p5IT1RPcdixe8z2rZfBwWPJkJPR
|
||||
6Sjd69YTxO38LyOU7+COECI1C7bRG08wQvAud7Grf7fOmDyUBXLC0DBMmTb3+4/f
|
||||
/XeOtpE+xtGqXJ0XKKRDCWzBXqcJPBRdM3ciMl1/8SPnwb4Pj4IPMGmwDWGXO3mr
|
||||
R0Y5VErXNS/AnRSvPjy6cMZUdKXquCzDDq/sfYYKIKylFJ1cG+uokoz5smhTRH4h
|
||||
NEP3Q+jPvbk0Fx3Fi04/U0Fs2eKGqO+NqC+fU4PLVsa2QYFSNG4ESNKfYDxTEc7n
|
||||
332VlDi8tY9rlgj0q3oZDbeXH00TXUOwCfO99W0ndKvknUXsuBfz5c1hQfkuszbv
|
||||
94WA5Jccxu/cU0nyRtoe4CVfGERAVO5vuojAX75T6TIrBlQxYdrf3YOrV6Mjfdw6
|
||||
R/Hkd1J88Zy+ayQTRH50T58j+D+3TmLlrKndyxosmVyC7QgV07hygqE+17X5mrU4
|
||||
R38qH7D8OoROXJHQZr6SbMT5jTbJBNe8iXMln8Jh1vPx0LbsoBcwIj0DWtrqgez5
|
||||
WG8yaucwiT3q5YRuKvRF0lwOtmCcAmaHX/CQ89tL5HZDBuPvSo6kUIODQH2vvJ2H
|
||||
Jts/SilES+84EpoN0My3DlsQ3X/BlQIDAQABAoICAQCzg5j9HgHaPap5ML5weCnv
|
||||
I86UgaESKvfShPmwzea3HMP+r1W3MRAk5QtFSFD+RN6+id96XKGFl+kwHoUyna4P
|
||||
1SymurarhyB5Zt/JUrWw0cgUzC/rmSzBgzC9WclH054yT6bNRv3o3Yo0o6kn+Svq
|
||||
LPzb7D8Bu0+ufQ3JtQYd849ubL4+1tN//jdrsx3E9xa6driIS8QkiZwsrwNHuMj0
|
||||
KQ/p3GYnpOHBMmaDWFCtdUjBkKb+kHc3Frvw2A0EmntgHH5ADzJdPUYLjIeMz0QR
|
||||
YDA5107bdGEjJCEQcSMJh3MW0NSasj4jg3dl1UH4bu0C5sBmc4jCkq+Q/Y93iqcF
|
||||
t57rBz9pcj/i6STaGRjl8ptC03O2dF6VBConCHCPcBMs8LDxJ7qbPWusRKXwbvWI
|
||||
kGQba/5PxPjDzVrf+ubXEtRhMDdCgnfLb8N313e9X9mBhTIkUX1hH5SFxZwJSuYt
|
||||
3TjobNVSfacjYAyjYHH/VjJB61j21BcEOuqpOqxh6Tf0bAEh014g7qCuIFCQwzxm
|
||||
FkpZNQPOTFk+bty4l9/brllvXoarJ2O9/L7mWtLq9d2AjRzU5XVCRZtPtovGyG6J
|
||||
qZFfP4kieU7E/gnVfCKuL4jMgkhlUtgAlfDVucEFCSFp3zVi/w8JUSmmCDIPEqmw
|
||||
BQ5aNBDtQmH7dVgQGPL6gQKCAQEA+ecsuhMMpdo11DCW+8T5XPFO0XEnDW/9j0Yf
|
||||
VAcOmw+GLtlJYTZzhqcD1ohVyQML8K0bn071pgt9tg1nwHk/oMDqE1nUOYq1+B9n
|
||||
ZNIUyKCSN+qc5Jg3O5J14CtPdGrkQH5CYw3Kp/lFBSIzvyaswblRgqO4X/Yc7sSF
|
||||
qwdleFgt8AyCcAqYi1g68gZ4P6VaN9ETvaG7rSbA6XaMMgiJ1yMWJ29OjXbCOWqi
|
||||
I+XK3jHWHKjmDYXW1hxo8mOTNNRBk4G4VzAWHVOZ71DoI2gL2aQP05bZMIwaWPq6
|
||||
aafVtHdLjXVX6ZO4bcerjN2pegbyaeWq++1OpjFxoU8tCuNb9QKCAQEA5+eKp6x3
|
||||
Rak9xkjQOAJUnQgzpEnkOrxk/+iUeKkT+Kf97yjuMhcfry+L5Iapsmm/rSPXynB+
|
||||
PXJi2YpEi2YWq6iUKECjQ+iu00f/IzAHw3bHNSN+ndTd4U55iMQjpjlttd5u63kb
|
||||
k/Il5CZpLQwElUZsJu+b2Xbx2Xwm06lyQbPLjOxXTQ3usunqCKWZmIig/MqrYCGO
|
||||
aVm8p9jFoyM8ApUwWdFjtDWExCWjRVg72L2P61DscIShBNMsW15J5YWvOMDeRbfY
|
||||
LnzkbvzO+FPqGix9RtwSakjqadsC48WIJmG8BJ6LeTBgaxJLS3MjZdtDgBEhiCyn
|
||||
bSupzT52kEvrIQKCAQEA1SnJzxa8rSQK0mp3/14vToZPMvwP0bh7UqD/zA8Q+Fcv
|
||||
n/qcSx3FQVBpR6+XbRT6NFuUujkB9JbMrmOU4msKSTcE82imTEbznSg8a5V/QPsm
|
||||
fifTmHH2ewwdHBAVgDpFZHXObuuut8U6iHx6I0z8A2ruCj9Y4BHw8AIV+qMefJ7K
|
||||
4H3rLWL8Z6/k59l47OFAqWfgLNsuWS47U2lZgLwjx60YEp8xJB3u4kcA8xnqB8n+
|
||||
1weC5HoLnSsyob8qK9/557a45TYRRwauwaIDwV/Y69az4UpFDNIVD10fcUxGT9+K
|
||||
RKmZSZZFtO6ieFADZiFS2d2cEbSEZ+K5CF2fEDCLmQKCAQAzW7H3ZFid0tddfG6w
|
||||
mMOIa3KUZdDnuSVdD3MiLb/Ah+PS/WuTKE/aClue5rvaSVUr0Q5PRQ6QKS7/IoH/
|
||||
pUJMDe+R4o7F0Vg2bnFwp4hwn3OKryuxZJ0m8qwzv8xtWbaUWoiSGaYj47LEjkUo
|
||||
tsqlDI2TKemIBbGWCsrGgXZOWrUvpn/HDtjYQrmT5KjZgRi2I2REy+mwJzGCsp1C
|
||||
feEiaUvl+FtuY5PdpMA98UZ/v6uJ38gdOyI14VanfYA8FpkxpnUTV53G3d3xPofP
|
||||
szXbShMMiFyDsVZIzzoam3qFlYuAMmSNGEuiKtEGCp/CRQbz69dTQyBpKwxV6glz
|
||||
93xBAoIBABr04VG9jN+Sk2G9v9ZCKr/t2K6Cku/4mjbkgTcHGauOF/urSWksoadj
|
||||
rhyQbik3XgG+8u+CI9hH/FxBK9f6aypn2/vtgzM+r8DuBn9AX7qK2hVvnUaI78/Y
|
||||
Hb0Ef7P8nwVbGYLv4Sw96yXJLa4FT0L2H/hRmNq11uMIKrQ+88+RK9JcZstn9oWV
|
||||
0AVYYXm83so+BtW6wwLflPEWicdpyMwd4qrJq6MHNhWwtGzTq2gqRpfuZvlYzp9D
|
||||
OQDWsStE54+m79Ymwi7HJV3frJeymXjTzWt/dXTfLtxkxVQOieSm/KDpvmiQzxGD
|
||||
TgdMxd0hKCEjjYqMbTgv5yjurUi9dD4=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Create private key file
|
||||
openssl genpkey -algorithm RSA \
|
||||
-pkeyopt rsa_keygen_bits:4096 \
|
||||
-pkeyopt rsa_keygen_pubexp:65537 | \
|
||||
openssl pkcs8 -topk8 -nocrypt -outform pem > voce.chat.key
|
||||
|
||||
# generate CSR file
|
||||
openssl req -subj "/C=US/ST=Arizona/L=Scottsdale/O=Vocechat,Inc./CN=voce.chat/emailAddress=api.privoce@gmail.com" \
|
||||
-new -days 3650 -key voce.chat.key -out voce.chat.csr
|
||||
|
||||
# generate self-sign file
|
||||
openssl x509 -signkey voce.chat.key -in voce.chat.csr -req -days 365 -out voce.chat.crt
|
||||
|
||||
# view certificate
|
||||
openssl req -text -noout -verify -in voce.chat.csr
|
||||
@@ -0,0 +1,7 @@
|
||||
[system]
|
||||
server_key = "a secret key"
|
||||
data_dir = "./data"
|
||||
|
||||
[network]
|
||||
bind = "0.0.0.0:3000"
|
||||
tls = true
|
||||
@@ -0,0 +1,46 @@
|
||||
# webclient_url = "https://github.com/Privoce/vocechat-web/releases/latest/download"
|
||||
webclient_url = "https://s.voce.chat/web_client/v0.3.x"
|
||||
|
||||
[system]
|
||||
data_dir = "./data"
|
||||
token_expiry_seconds = 300
|
||||
refresh_token_expiry_seconds = 604800
|
||||
# magic_token_expiry_seconds = 600
|
||||
|
||||
[network]
|
||||
bind = "0.0.0.0:3000"
|
||||
# domain = "chat.privoce.com"
|
||||
frontend_url = "http://1.2.3.4:4000"
|
||||
|
||||
# [network.tls]
|
||||
# type = "self_signed"
|
||||
|
||||
# [network.tls]
|
||||
# type = "certificate"
|
||||
# cert = "...."
|
||||
# key = "...."
|
||||
# path = "./cert/"
|
||||
|
||||
# [network.tls]
|
||||
# type = "acme_http_01"
|
||||
# directory_url = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
# http_bind = "0.0.0.0:80"
|
||||
# cache_path = "./data/cert"
|
||||
|
||||
# [network.tls]
|
||||
# type = "acme_tls_alpn_01"
|
||||
# directory_url = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
# cache_path = "./data/cert"
|
||||
|
||||
[template.register_by_email]
|
||||
subject = "Register code"
|
||||
file = "templates/register_by_email.html"
|
||||
|
||||
[template.login_by_email]
|
||||
subject = "Your sign-in link for Vocechat"
|
||||
file = "templates/login_by_email.html"
|
||||
|
||||
#[[user]]
|
||||
#name = "Guest"
|
||||
#password = "123456"
|
||||
#email = "guest@voce.chat"
|
||||
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VoceChat Link</title>
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100% !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
#backgroundTable {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
line-height: 100% !important;
|
||||
}
|
||||
|
||||
img {
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.image_fix {
|
||||
display: block;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
h1 a,
|
||||
h2 a,
|
||||
h3 a,
|
||||
h4 a,
|
||||
h5 a,
|
||||
h6 a {
|
||||
color: blue !important;
|
||||
}
|
||||
|
||||
h1 a:active,
|
||||
h2 a:active,
|
||||
h3 a:active,
|
||||
h4 a:active,
|
||||
h5 a:active,
|
||||
h6 a:active {
|
||||
color: red !important;
|
||||
}
|
||||
|
||||
h1 a:visited,
|
||||
h2 a:visited,
|
||||
h3 a:visited,
|
||||
h4 a:visited,
|
||||
h5 a:visited,
|
||||
h6 a:visited {
|
||||
color: purple !important;
|
||||
}
|
||||
|
||||
table td {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
a {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
@media only screen and (max-device-width: 480px) {
|
||||
|
||||
a[href^="tel"],
|
||||
a[href^="sms"] {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
/* or whatever your want */
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mobile_link a[href^="tel"],
|
||||
.mobile_link a[href^="sms"] {
|
||||
text-decoration: default;
|
||||
color: orange !important;
|
||||
/* or whatever your want */
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) {
|
||||
|
||||
a[href^="tel"],
|
||||
a[href^="sms"] {
|
||||
text-decoration: none;
|
||||
color: blue;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mobile_link a[href^="tel"],
|
||||
.mobile_link a[href^="sms"] {
|
||||
text-decoration: default;
|
||||
color: orange !important;
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
#email_body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif !important;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#email_body .p {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #1D2939;
|
||||
}
|
||||
|
||||
#email_body .logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
#email_body .btn {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
margin: 32px 0;
|
||||
padding: 12px 20px;
|
||||
text-decoration: none;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #FFFFFF;
|
||||
background: #22CCEE;
|
||||
border: 1px solid #22CCEE;
|
||||
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#email_body .hl {
|
||||
color: #22CCEE;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="email_body">
|
||||
<img src="https://s.voce.chat/logo.png" alt="Vocechat logo" width="64" height="64" class="logo" />
|
||||
<div class="tip">
|
||||
<div class="p">
|
||||
Click the button below to log in to Vocechat.
|
||||
</div>
|
||||
<div class="p">
|
||||
This button will expire in 3 minutes.
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url }}/?magic_token={{ magic_token }}&exists={{ exists }}#/login" class="btn">Login to
|
||||
Vocechat</a>
|
||||
<div class="tail">
|
||||
<div class="p">Button not showing? <a
|
||||
href="{{ url }}/?magic_token={{ magic_token }}&exists={{ exists }}#/login" class="hl link">Click
|
||||
here</a>
|
||||
</div>
|
||||
<div class="p">Confirming this request will securely log you in using <span class="hl">
|
||||
{{ email }}
|
||||
</span>.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VoceChat Link</title>
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100% !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
#backgroundTable {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
line-height: 100% !important;
|
||||
}
|
||||
|
||||
img {
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.image_fix {
|
||||
display: block;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
h1 a,
|
||||
h2 a,
|
||||
h3 a,
|
||||
h4 a,
|
||||
h5 a,
|
||||
h6 a {
|
||||
color: blue !important;
|
||||
}
|
||||
|
||||
h1 a:active,
|
||||
h2 a:active,
|
||||
h3 a:active,
|
||||
h4 a:active,
|
||||
h5 a:active,
|
||||
h6 a:active {
|
||||
color: red !important;
|
||||
}
|
||||
|
||||
h1 a:visited,
|
||||
h2 a:visited,
|
||||
h3 a:visited,
|
||||
h4 a:visited,
|
||||
h5 a:visited,
|
||||
h6 a:visited {
|
||||
color: purple !important;
|
||||
}
|
||||
|
||||
table td {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
a {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
@media only screen and (max-device-width: 480px) {
|
||||
|
||||
a[href^="tel"],
|
||||
a[href^="sms"] {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
/* or whatever your want */
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mobile_link a[href^="tel"],
|
||||
.mobile_link a[href^="sms"] {
|
||||
text-decoration: default;
|
||||
color: orange !important;
|
||||
/* or whatever your want */
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) {
|
||||
|
||||
a[href^="tel"],
|
||||
a[href^="sms"] {
|
||||
text-decoration: none;
|
||||
color: blue;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mobile_link a[href^="tel"],
|
||||
.mobile_link a[href^="sms"] {
|
||||
text-decoration: default;
|
||||
color: orange !important;
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
#email_body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif !important;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#email_body .p {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #1D2939;
|
||||
}
|
||||
|
||||
#email_body .logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
#email_body .btn {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
margin: 32px 0;
|
||||
padding: 12px 20px;
|
||||
text-decoration: none;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #FFFFFF;
|
||||
background: #22CCEE;
|
||||
border: 1px solid #22CCEE;
|
||||
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#email_body .hl {
|
||||
color: #22CCEE;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="email_body">
|
||||
<img src="https://s.voce.chat/logo.png" alt="Vocechat logo" width="64" height="64" class="logo" />
|
||||
<div class="tip">
|
||||
<div class="p">
|
||||
Click the button below to log in to Vocechat.
|
||||
</div>
|
||||
<div class="p">
|
||||
This button will expire in 3 minutes.
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url }}/?email={{ email }}&gid={{ gid }}&magic_token={{ magic_token }}#/register/set_name"
|
||||
class="btn">Login to
|
||||
Vocechat</a>
|
||||
<div class="tail">
|
||||
<div class="p">Button not showing? <a
|
||||
href="{{ url }}/?email={{ email }}&gid={{ gid }}&magic_token={{ magic_token }}#/register/set_name"
|
||||
class="hl link">Click here</a>
|
||||
</div>
|
||||
<div class="p">Confirming this request will securely log you in using <span class="hl">
|
||||
{{ email }}
|
||||
</span>.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "agora-token"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.13.0"
|
||||
checksum = "0.2.1"
|
||||
fastrand = "1.6.0"
|
||||
hmac = "0.11.0"
|
||||
sha2 = "0.9"
|
||||
thiserror = "1.0.30"
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use hmac::{Mac, NewMac};
|
||||
use sha2::Sha256;
|
||||
|
||||
const VERSION: &str = "006";
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
enum Privileges {
|
||||
JoinChannel = 1,
|
||||
PublishAudioStream = 2,
|
||||
PublishVideoStream = 3,
|
||||
PublishDataStream = 4,
|
||||
}
|
||||
|
||||
fn create_access_token(
|
||||
app_id: &str,
|
||||
app_certificate: &str,
|
||||
channel_name: &str,
|
||||
uid: &str,
|
||||
privileges: &[Privileges],
|
||||
) -> String {
|
||||
let ts = (SystemTime::now().duration_since(UNIX_EPOCH))
|
||||
.unwrap()
|
||||
.as_secs() as u32
|
||||
+ 24 * 3600;
|
||||
let salt = fastrand::u32(1..99999999);
|
||||
let mut buf_m = Vec::new();
|
||||
|
||||
buf_m.extend_from_slice(&salt.to_le_bytes());
|
||||
buf_m.extend_from_slice(&ts.to_le_bytes());
|
||||
|
||||
buf_m.extend_from_slice(&(privileges.len() as u16).to_le_bytes());
|
||||
let mut privileges = privileges.to_vec();
|
||||
privileges.sort();
|
||||
|
||||
for k in privileges {
|
||||
buf_m.extend_from_slice(&(k as u16).to_le_bytes());
|
||||
buf_m.extend_from_slice(&ts.to_le_bytes());
|
||||
}
|
||||
|
||||
let mut buf_val = Vec::new();
|
||||
buf_val.extend(format!("{}{}{}", app_id, channel_name, uid).as_bytes());
|
||||
buf_val.extend(&buf_m);
|
||||
|
||||
let buf_sig = {
|
||||
let mut mac = hmac::Hmac::<Sha256>::new_from_slice(app_certificate.as_bytes())
|
||||
.expect("valid app certificate");
|
||||
mac.update(&buf_val);
|
||||
mac.finalize().into_bytes()
|
||||
};
|
||||
|
||||
let crc_channel_name = crc32(channel_name.as_bytes());
|
||||
let crc_uid = crc32(uid.as_bytes());
|
||||
|
||||
let mut buf_content = Vec::new();
|
||||
pack_bytes(&mut buf_content, &buf_sig);
|
||||
buf_content.extend_from_slice(&crc_channel_name.to_le_bytes());
|
||||
buf_content.extend_from_slice(&crc_uid.to_le_bytes());
|
||||
pack_bytes(&mut buf_content, &buf_m);
|
||||
|
||||
format!("{}{}{}", VERSION, app_id, base64::encode(buf_content))
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut hasher = checksum::crc32::Crc32::new();
|
||||
hasher.checksum(data)
|
||||
}
|
||||
|
||||
fn pack_bytes(data: &mut Vec<u8>, s: &[u8]) {
|
||||
data.extend_from_slice(&(s.len() as u16).to_le_bytes());
|
||||
data.extend_from_slice(s);
|
||||
}
|
||||
|
||||
pub fn create_rtc_token(
|
||||
app_id: &str,
|
||||
app_certificate: &str,
|
||||
channel_name: &str,
|
||||
uid: u32,
|
||||
) -> String {
|
||||
let uid = if uid == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
uid.to_string()
|
||||
};
|
||||
create_access_token(
|
||||
app_id,
|
||||
app_certificate,
|
||||
channel_name,
|
||||
&uid,
|
||||
&[
|
||||
Privileges::JoinChannel,
|
||||
Privileges::PublishAudioStream,
|
||||
Privileges::PublishVideoStream,
|
||||
Privileges::PublishDataStream,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "rc-fcm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.52"
|
||||
base64 = "0.13.0"
|
||||
chrono = "0.4.19"
|
||||
reqwest = { version = "0.11.8", default-features = false, features = ["rustls-tls", "json"] }
|
||||
rustls = "0.20.2"
|
||||
rustls-pemfile = "0.3.0"
|
||||
serde = { version = "1.0.132", features = ["derive"] }
|
||||
serde_json = "1.0.73"
|
||||
thiserror = "1.0.30"
|
||||
tokio = { version = "1.15.0", features = ["sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,153 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
jwt::{Claims, JwtSigner},
|
||||
ApplicationCredentials,
|
||||
};
|
||||
|
||||
const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
||||
const SCOPES: &[&str] = &["https://www.googleapis.com/auth/firebase.messaging"];
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct Token {
|
||||
access_token: String,
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_expires_in",
|
||||
rename(deserialize = "expires_in")
|
||||
)]
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
fn has_expired(&self) -> bool {
|
||||
self.expires_at
|
||||
.map(|expiration_time| expiration_time - Duration::seconds(30) <= Utc::now())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
&self.access_token
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_expires_in<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: Option<i64> = Deserialize::deserialize(deserializer)?;
|
||||
Ok(s.map(|seconds_from_now| Utc::now() + Duration::seconds(seconds_from_now)))
|
||||
}
|
||||
|
||||
pub struct FcmClient {
|
||||
client: Client,
|
||||
credentials: ApplicationCredentials,
|
||||
token: Mutex<Option<Token>>,
|
||||
}
|
||||
|
||||
impl FcmClient {
|
||||
pub fn new(credentials: ApplicationCredentials) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
credentials,
|
||||
token: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn credentials(&self) -> &ApplicationCredentials {
|
||||
&self.credentials
|
||||
}
|
||||
|
||||
async fn refresh_token(&self) -> Result<Token> {
|
||||
let signer = JwtSigner::new(&self.credentials.private_key)?;
|
||||
let claims = Claims::new(&self.credentials, SCOPES, None);
|
||||
let signed = signer.sign_claims(&claims)?;
|
||||
let token = self
|
||||
.client
|
||||
.post(&self.credentials.token_uri)
|
||||
.form(&[("grant_type", GRANT_TYPE), ("assertion", signed.as_str())])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Token>()
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn send(
|
||||
&self,
|
||||
device_token: &str,
|
||||
title: &str,
|
||||
message: &str,
|
||||
data: &impl Serialize,
|
||||
) -> Result<()> {
|
||||
let token = loop {
|
||||
let mut token = self.token.lock().await;
|
||||
match &*token {
|
||||
Some(token) if !token.has_expired() => break token.as_str().to_string(),
|
||||
_ => *token = Some(self.refresh_token().await?),
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"https://fcm.googleapis.com/v1/projects/{}/messages:send",
|
||||
self.credentials.project_id
|
||||
);
|
||||
|
||||
let _ = reqwest::Client::new()
|
||||
.post(url)
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({
|
||||
"message": {
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": message,
|
||||
"sound": "default",
|
||||
},
|
||||
"data": data,
|
||||
"token": device_token,
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send() {
|
||||
let credentials = ApplicationCredentials {
|
||||
project_id: "Your project".to_string(),
|
||||
private_key: "Your key".to_string(),
|
||||
client_email: "Your client email"
|
||||
.to_string(),
|
||||
token_uri: "https://oauth2.googleapis.com/token".to_string(),
|
||||
};
|
||||
|
||||
let client = FcmClient::new(credentials);
|
||||
let device_token = "Your client token";
|
||||
client
|
||||
.send(
|
||||
device_token,
|
||||
"title",
|
||||
"hello!",
|
||||
&json!({
|
||||
"vocechat_server_id": "your server id",
|
||||
"vocechat_from_uid": "123",
|
||||
"vocechat_to_uid": "123123",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ApplicationCredentials {
|
||||
pub project_id: String,
|
||||
pub private_key: String,
|
||||
pub client_email: String,
|
||||
pub token_uri: String,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use rustls::{sign, sign::SigningKey, PrivateKey};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ApplicationCredentials;
|
||||
|
||||
const GOOGLE_RS256_HEAD: &str = r#"{"alg":"RS256","typ":"JWT"}"#;
|
||||
|
||||
/// Encodes s as Base64
|
||||
fn append_base64<T: AsRef<[u8]> + ?Sized>(s: &T, out: &mut String) {
|
||||
base64::encode_config_buf(s, base64::URL_SAFE, out)
|
||||
}
|
||||
|
||||
/// Decode a PKCS8 formatted RSA key.
|
||||
fn decode_rsa_key(pem_pkcs8: &str) -> Result<PrivateKey> {
|
||||
let mut private_keys = rustls_pemfile::pkcs8_private_keys(&mut pem_pkcs8.as_bytes())?;
|
||||
anyhow::ensure!(!private_keys.is_empty(), "Not enough private keys in PEM");
|
||||
Ok(PrivateKey(private_keys.remove(0)))
|
||||
}
|
||||
|
||||
/// Permissions requested for a JWT.
|
||||
/// See https://developers.google.com/identity/protocols/OAuth2ServiceAccount#authorizingrequests.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct Claims<'a> {
|
||||
iss: &'a str,
|
||||
aud: &'a str,
|
||||
exp: i64,
|
||||
iat: i64,
|
||||
subject: Option<&'a str>,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
impl<'a> Claims<'a> {
|
||||
pub(crate) fn new<T>(
|
||||
key: &'a ApplicationCredentials,
|
||||
scopes: &[T],
|
||||
subject: Option<&'a str>,
|
||||
) -> Self
|
||||
where
|
||||
T: std::string::ToString,
|
||||
{
|
||||
let iat = Utc::now().timestamp();
|
||||
let expiry = iat + 3600 - 5; // Max validity is 1h.
|
||||
|
||||
let scope: String = scopes
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Claims {
|
||||
iss: &key.client_email,
|
||||
aud: &key.token_uri,
|
||||
exp: expiry,
|
||||
iat,
|
||||
subject,
|
||||
scope,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON Web Token ready for signing.
|
||||
pub(crate) struct JwtSigner {
|
||||
signer: Box<dyn rustls::sign::Signer>,
|
||||
}
|
||||
|
||||
impl JwtSigner {
|
||||
pub(crate) fn new(private_key: &str) -> Result<Self> {
|
||||
let key = decode_rsa_key(private_key)?;
|
||||
let signing_key = sign::RsaSigningKey::new(&key)?;
|
||||
let signer = signing_key
|
||||
.choose_scheme(&[rustls::SignatureScheme::RSA_PKCS1_SHA256])
|
||||
.ok_or_else(|| anyhow::anyhow!("Couldn't choose signing scheme"))?;
|
||||
Ok(JwtSigner { signer })
|
||||
}
|
||||
|
||||
pub(crate) fn sign_claims(&self, claims: &Claims) -> Result<String, rustls::Error> {
|
||||
let mut jwt_head = Self::encode_claims(claims);
|
||||
let signature = self.signer.sign(jwt_head.as_bytes())?;
|
||||
jwt_head.push('.');
|
||||
append_base64(&signature, &mut jwt_head);
|
||||
Ok(jwt_head)
|
||||
}
|
||||
|
||||
/// Encodes the first two parts (header and claims) to base64 and assembles
|
||||
/// them into a form ready to be signed.
|
||||
fn encode_claims(claims: &Claims) -> String {
|
||||
let mut head = String::new();
|
||||
append_base64(GOOGLE_RS256_HEAD, &mut head);
|
||||
head.push('.');
|
||||
append_base64(&serde_json::to_string(&claims).unwrap(), &mut head);
|
||||
head
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod client;
|
||||
mod credentials;
|
||||
mod jwt;
|
||||
|
||||
pub use client::FcmClient;
|
||||
pub use credentials::ApplicationCredentials;
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "github-oauth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "rc-magic-link"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.132", features = ["derive"] }
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
hmac = "0.11.0"
|
||||
sha2 = "0.9.0"
|
||||
bincode = "1.3.3"
|
||||
fastrand = "1.6.0"
|
||||
hex = "0.4.3"
|
||||
@@ -0,0 +1,149 @@
|
||||
extern crate core;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use hmac::{Mac, NewMac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum MagicLinkToken {
|
||||
Register {
|
||||
is_confirmed: bool,
|
||||
// in_confirmed -> is_email_confirmed
|
||||
gid: Option<i64>,
|
||||
code: String,
|
||||
expired_at: i64,
|
||||
extra_email: Option<String>,
|
||||
extra_password: Option<String>,
|
||||
},
|
||||
Login {
|
||||
email: String,
|
||||
uid: Option<i64>,
|
||||
code: String,
|
||||
expired_at: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl MagicLinkToken {
|
||||
pub fn gen_reg_magic_token(
|
||||
code: &str,
|
||||
server_key: &str,
|
||||
expired_at: DateTime<Utc>,
|
||||
is_confirmed: bool,
|
||||
gid: Option<i64>,
|
||||
extra_email: Option<String>,
|
||||
extra_password: Option<String>,
|
||||
) -> String {
|
||||
encode(
|
||||
server_key,
|
||||
&MagicLinkToken::Register {
|
||||
is_confirmed,
|
||||
gid,
|
||||
code: code.to_string(),
|
||||
expired_at: expired_at.timestamp(), /* : (Utc::now() + Duration::seconds(expired_in)).timestamp() */
|
||||
extra_email,
|
||||
extra_password,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn gen_login_magic_token(
|
||||
code: &str,
|
||||
server_key: &str,
|
||||
expired_at: DateTime<Utc>,
|
||||
email: &str,
|
||||
uid: Option<i64>,
|
||||
) -> String {
|
||||
encode(
|
||||
server_key,
|
||||
&MagicLinkToken::Login {
|
||||
email: email.to_string(),
|
||||
uid,
|
||||
code: code.to_string(),
|
||||
expired_at: expired_at.timestamp(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse(server_key: &str, s: impl AsRef<str>) -> Option<MagicLinkToken> {
|
||||
if s.as_ref().is_empty() {
|
||||
return None;
|
||||
}
|
||||
// ya29.a0ARrdaM-5RaNLVVbJQSCYlekpYmPqhUXpG4THaOOYA0kUJ8jUd_rLFEgRappLi9fdmFpvdD7keGos2jzcnl0Vivh4C3HDNbOJpqCtwEQvH8vIYGUayAeMNMKGowkAhH0zCCprUGNBdQaZ65EW8Gf3kKcPc2GH
|
||||
let data = hex::decode(s.as_ref()).ok()?;
|
||||
assert!(data.len() >= 32);
|
||||
let mut mac = hmac::Hmac::<Sha256>::new_from_slice(server_key.as_bytes()).unwrap();
|
||||
mac.update(&data[32..]);
|
||||
mac.verify(&data[..32]).ok()?;
|
||||
let magic_token = bincode::deserialize::<MagicLinkToken>(&data[32..]).ok()?;
|
||||
if magic_token.get_expired_at() < Utc::now().timestamp() {
|
||||
return None;
|
||||
}
|
||||
Some(magic_token)
|
||||
}
|
||||
|
||||
pub fn get_expired_at(&self) -> i64 {
|
||||
match &self {
|
||||
MagicLinkToken::Register { expired_at, .. } => *expired_at,
|
||||
MagicLinkToken::Login { expired_at, .. } => *expired_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_code(&self) -> &str {
|
||||
match &self {
|
||||
MagicLinkToken::Register { code, .. } => code.as_str(),
|
||||
MagicLinkToken::Login { code, .. } => code.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gen_code() -> String {
|
||||
(0..6)
|
||||
.map(|_| fastrand::char('0'..='9'))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn encode(server_key: &str, magictoken: &MagicLinkToken) -> String {
|
||||
let content = bincode::serialize(&magictoken).unwrap();
|
||||
let mut buf_sig = {
|
||||
let mut mac = hmac::Hmac::<Sha256>::new_from_slice(server_key.as_bytes()).unwrap();
|
||||
mac.update(&content);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
};
|
||||
buf_sig.extend_from_slice(&content);
|
||||
hex::encode(&buf_sig)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encode_invite_to_server() {
|
||||
let server_key = "123456";
|
||||
let code = gen_code();
|
||||
let expired_at = chrono::Utc::now() + chrono::Duration::seconds(10);
|
||||
let token = MagicLinkToken::gen_reg_magic_token(
|
||||
&code, server_key, expired_at, true, None, None, None,
|
||||
);
|
||||
if let MagicLinkToken::Register {
|
||||
is_confirmed,
|
||||
gid,
|
||||
code,
|
||||
expired_at,
|
||||
extra_email,
|
||||
extra_password,
|
||||
} = MagicLinkToken::parse(server_key, token).unwrap()
|
||||
{
|
||||
assert!(is_confirmed);
|
||||
assert_eq!(gid, None);
|
||||
assert_eq!(code.len(), 6);
|
||||
assert!(expired_at > 0);
|
||||
assert_eq!(extra_email, None);
|
||||
assert_eq!(extra_password, None);
|
||||
} else {
|
||||
panic!("test failed!");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+768
@@ -0,0 +1,768 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c24dab4283a142afa2fdca129b80ad2c6284e073930f964c3a1293c225ee39a"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"textwrap",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1604dafd25fba2fe2d5895a9da139f8dc9b319a5fe5354ca137cbbce4e178d10"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"cast",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"csv",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_cbor",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d00996de9f2f7559f7f4dc286073197f83e92256a59ed395f9aac01fe717da57"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e54ea8bc3fb1ee042f5aace6e3c6e025d3874866da222930f70ce62aceba0bfa"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c00d6d2ea26e8b151d99093005cb442fb9a37aeaca582a03ec70946f49ab5ed9"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"lazy_static",
|
||||
"memoffset",
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e5bed1f1c269533fa816a0a5492b3545209a205ca1a54842be180eb63a16a6"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"csv-core",
|
||||
"itoa 0.4.8",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv-core"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf"
|
||||
dependencies = [
|
||||
"instant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
|
||||
dependencies = [
|
||||
"instant",
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"instant",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a3fd9ec30b9749ce28cd91f255d569591cdf937fe280c312143e3c4bad6f2a"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d88417318da0eaf0fdcdb51a0ee6c3bed624333bff8f946733049380be67ac1c"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521fa9638fa597e1dc53e9412a4f9cefb01187ee1f7413076f9e6749e2885ba9"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c06aca804d41dbc8ba42dfd964f0d01334eceb64314b9ecf7c5fad5188a06d90"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"crossbeam-deque",
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78120e2c850279833f1dd3582f730c4ab53ed95aeaaaa862a2a5c71b1656d8e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
"lazy_static",
|
||||
"num_cpus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rc-msgdb"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"sled",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8380fe0152551244f0747b1bf41737e0f8a74f97a14ccefd1148187271634f3c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
|
||||
|
||||
[[package]]
|
||||
name = "remove_dir_all"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a3381e03edd24287172047536f20cabde766e2cd3e65e6b00fb3af51c4f38d"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.136"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789"
|
||||
|
||||
[[package]]
|
||||
name = "serde_cbor"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
|
||||
dependencies = [
|
||||
"half",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.136"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95"
|
||||
dependencies = [
|
||||
"itoa 1.0.1",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sled"
|
||||
version = "0.34.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"fs2",
|
||||
"fxhash",
|
||||
"libc",
|
||||
"log",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.86"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"remove_dir_all",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2"
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "rc-msgdb"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
parking_lot = "0.12.0"
|
||||
sled = "0.34.7"
|
||||
thiserror = "1.0.30"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.3.5", features = ["html_reports"] }
|
||||
tempfile = "3.2.0"
|
||||
|
||||
[[bench]]
|
||||
name = "db"
|
||||
harness = false
|
||||
@@ -0,0 +1,43 @@
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use rc_msgdb::MsgDb;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn send_benchmark(c: &mut Criterion) {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = MsgDb::open(dir.path()).unwrap();
|
||||
let to = (0..50000i64).collect::<Vec<_>>();
|
||||
let msg = b"hello!";
|
||||
c.bench_function("send", |b| {
|
||||
b.iter(|| {
|
||||
db.messages()
|
||||
.send_to_group(1, to.iter().copied(), msg)
|
||||
.unwrap();
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch_benchmark(c: &mut Criterion) {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = MsgDb::open(dir.path()).unwrap();
|
||||
let to = vec![1];
|
||||
let msg = b"hello!";
|
||||
for _ in 0..1000 {
|
||||
db.messages()
|
||||
.send_to_group(1, to.iter().copied(), msg)
|
||||
.unwrap();
|
||||
}
|
||||
c.bench_function("fetch", |b| {
|
||||
b.iter(|| {
|
||||
assert_eq!(
|
||||
db.messages()
|
||||
.fetch_user_messages_after(1, None, 10000)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1000
|
||||
);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, send_benchmark, fetch_benchmark);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::path::Path;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use sled::Db;
|
||||
|
||||
use crate::{sequence::Sequence, Messages, Result};
|
||||
|
||||
const MSG_SEQUENCE: u8 = 1;
|
||||
|
||||
pub struct MsgDb {
|
||||
pub(crate) db: Db,
|
||||
msg_sequence: Mutex<Sequence>,
|
||||
}
|
||||
|
||||
impl Drop for MsgDb {
|
||||
fn drop(&mut self) {
|
||||
self.msg_sequence.lock().release(&self.db);
|
||||
}
|
||||
}
|
||||
|
||||
impl MsgDb {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let db = sled::open(path)?;
|
||||
let msg_sequence = Mutex::new(Sequence::new(&db, MSG_SEQUENCE)?);
|
||||
Ok(Self { db, msg_sequence })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn messages(&self) -> Messages {
|
||||
Messages { db: self }
|
||||
}
|
||||
|
||||
pub(crate) fn generate_msg_id(&self) -> Result<i64> {
|
||||
self.msg_sequence.lock().generate_id(&self.db)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Db(#[from] sled::Error),
|
||||
|
||||
#[error("invalid data")]
|
||||
InvalidData,
|
||||
}
|
||||
|
||||
pub type Result<T, E = Error> = ::std::result::Result<T, E>;
|
||||
@@ -0,0 +1,8 @@
|
||||
mod db;
|
||||
mod error;
|
||||
mod messages;
|
||||
mod sequence;
|
||||
|
||||
pub use db::MsgDb;
|
||||
pub use error::{Error, Result};
|
||||
pub use messages::Messages;
|
||||
@@ -0,0 +1,238 @@
|
||||
use sled::Batch;
|
||||
|
||||
use crate::{MsgDb, Result};
|
||||
|
||||
pub struct Messages<'a> {
|
||||
pub(crate) db: &'a MsgDb,
|
||||
}
|
||||
|
||||
impl<'a> Messages<'a> {
|
||||
pub fn get(&self, mid: i64) -> Result<Option<Vec<u8>>> {
|
||||
Ok(self.db.db.get(key_msg(mid))?.map(|data| data.to_vec()))
|
||||
}
|
||||
|
||||
pub fn send_to_group(
|
||||
&self,
|
||||
gid: i64,
|
||||
to: impl IntoIterator<Item = i64>,
|
||||
msg: &[u8],
|
||||
) -> Result<i64> {
|
||||
let id = self.db.generate_msg_id()?;
|
||||
let mut batch = Batch::default();
|
||||
batch.insert(&key_msg(id), msg);
|
||||
for target_uid in to {
|
||||
batch.insert(&key_user_msg(target_uid, id), msg);
|
||||
}
|
||||
batch.insert(&key_group_msg(gid, id), msg);
|
||||
self.db.db.apply_batch(batch)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn send_to_dm(&self, from_uid: i64, to_uid: i64, msg: &[u8]) -> Result<i64> {
|
||||
let id = self.db.generate_msg_id()?;
|
||||
let mut batch = Batch::default();
|
||||
batch.insert(&key_msg(id), msg);
|
||||
for target_uid in [from_uid, to_uid] {
|
||||
batch.insert(&key_user_msg(target_uid, id), msg);
|
||||
}
|
||||
batch.insert(&key_dm_msg(from_uid, to_uid, id), msg);
|
||||
self.db.db.apply_batch(batch)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn fetch_user_messages_after(
|
||||
&self,
|
||||
uid: i64,
|
||||
after: Option<i64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(i64, Vec<u8>)>> {
|
||||
let after_id = after.map(|id| id + 1).unwrap_or_default();
|
||||
let iter = self
|
||||
.db
|
||||
.db
|
||||
.range(key_user_msg(uid, after_id)..key_user_msg(uid, i64::MAX))
|
||||
.rev();
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
for item in iter.take(limit) {
|
||||
let (key, value) = item?;
|
||||
let (current_uid, msg_id) = match decode_key_user_msg(&key) {
|
||||
Some(res) => res,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if current_uid != uid {
|
||||
break;
|
||||
}
|
||||
|
||||
msgs.push((msg_id, value.to_vec()));
|
||||
}
|
||||
|
||||
msgs.reverse();
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
pub fn fetch_dm_messages_before(
|
||||
&self,
|
||||
from_uid: i64,
|
||||
to_uid: i64,
|
||||
before: Option<i64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(i64, Vec<u8>)>> {
|
||||
let before_id = before.unwrap_or(i64::MAX);
|
||||
let iter = self
|
||||
.db
|
||||
.db
|
||||
.range(key_dm_msg(from_uid, to_uid, 0)..key_dm_msg(from_uid, to_uid, before_id))
|
||||
.rev()
|
||||
.take(limit);
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
for item in iter {
|
||||
let (key, value) = item?;
|
||||
let (a, b, msg_id) = match decode_key_dm_msg(&key) {
|
||||
Some(res) => res,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if !(from_uid == a && to_uid == b || from_uid == b && to_uid == a) {
|
||||
break;
|
||||
}
|
||||
|
||||
msgs.push((msg_id, value.to_vec()));
|
||||
}
|
||||
|
||||
msgs.reverse();
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
pub fn fetch_group_messages_before(
|
||||
&self,
|
||||
gid: i64,
|
||||
before: Option<i64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(i64, Vec<u8>)>> {
|
||||
let before_id = before.unwrap_or(i64::MAX);
|
||||
let iter = self
|
||||
.db
|
||||
.db
|
||||
.range(key_group_msg(gid, 0)..key_group_msg(gid, before_id))
|
||||
.rev()
|
||||
.take(limit);
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
for item in iter {
|
||||
let (key, value) = item?;
|
||||
let (current_gid, msg_id) = match decode_key_group_msg(&key) {
|
||||
Some(res) => res,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if current_gid != gid {
|
||||
break;
|
||||
}
|
||||
|
||||
msgs.push((msg_id, value.to_vec()));
|
||||
}
|
||||
|
||||
msgs.reverse();
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
pub fn insert_merged_msg(&self, mid: i64, msg: &[u8]) -> Result<()> {
|
||||
self.db.db.insert(key_merged_msg(mid), msg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_merged_msg(&self, mid: i64, mut f: impl FnMut(&[u8]) -> Vec<u8>) -> Result<()> {
|
||||
self.db
|
||||
.db
|
||||
.update_and_fetch(key_merged_msg(mid), |data| data.map(&mut f))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_merged_msg(&self, mid: i64) -> Result<()> {
|
||||
self.db.db.remove(key_merged_msg(mid))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_merged_msg(&self, mid: i64) -> Result<Option<Vec<u8>>> {
|
||||
Ok(self
|
||||
.db
|
||||
.db
|
||||
.get(key_merged_msg(mid))?
|
||||
.map(|data| data.to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
fn key_msg(msg_id: i64) -> [u8; 12] {
|
||||
let mut data = [0; 12];
|
||||
data[0..4].copy_from_slice(b"MSG/");
|
||||
data[4..12].copy_from_slice(&msg_id.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn key_merged_msg(msg_id: i64) -> [u8; 13] {
|
||||
let mut data = [0; 13];
|
||||
data[0..5].copy_from_slice(b"FMSG/");
|
||||
data[5..13].copy_from_slice(&msg_id.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn key_user_msg(uid: i64, msg_id: i64) -> [u8; 21] {
|
||||
let mut data = [0; 21];
|
||||
data[0..5].copy_from_slice(b"UMSG/");
|
||||
data[5..13].copy_from_slice(&uid.to_be_bytes());
|
||||
data[13..21].copy_from_slice(&msg_id.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn decode_key_user_msg(data: &[u8]) -> Option<(i64, i64)> {
|
||||
let data = data.strip_prefix(b"UMSG/")?;
|
||||
if data.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let uid = i64::from_be_bytes(data[0..8].try_into().unwrap());
|
||||
let msg_id = i64::from_be_bytes(data[8..16].try_into().unwrap());
|
||||
Some((uid, msg_id))
|
||||
}
|
||||
|
||||
fn key_group_msg(gid: i64, msg_id: i64) -> [u8; 21] {
|
||||
let mut data = [0; 21];
|
||||
data[0..5].copy_from_slice(b"GMSG/");
|
||||
data[5..13].copy_from_slice(&gid.to_be_bytes());
|
||||
data[13..21].copy_from_slice(&msg_id.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn decode_key_group_msg(data: &[u8]) -> Option<(i64, i64)> {
|
||||
let data = data.strip_prefix(b"GMSG/")?;
|
||||
if data.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let gid = i64::from_be_bytes(data[0..8].try_into().unwrap());
|
||||
let msg_id = i64::from_be_bytes(data[8..16].try_into().unwrap());
|
||||
Some((gid, msg_id))
|
||||
}
|
||||
|
||||
fn key_dm_msg(from_uid: i64, to_uid: i64, msg_id: i64) -> [u8; 27] {
|
||||
let mut data = [0; 27];
|
||||
let a = from_uid.min(to_uid);
|
||||
let b = from_uid.max(to_uid);
|
||||
data[0..3].copy_from_slice(b"DM/");
|
||||
data[3..11].copy_from_slice(&a.to_be_bytes());
|
||||
data[11..19].copy_from_slice(&b.to_be_bytes());
|
||||
data[19..27].copy_from_slice(&msg_id.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn decode_key_dm_msg(data: &[u8]) -> Option<(i64, i64, i64)> {
|
||||
let data = data.strip_prefix(b"DM/")?;
|
||||
if data.len() != 24 {
|
||||
return None;
|
||||
}
|
||||
let from_uid = i64::from_be_bytes(data[0..8].try_into().unwrap());
|
||||
let to_uid = i64::from_be_bytes(data[8..16].try_into().unwrap());
|
||||
let msg_id = i64::from_be_bytes(data[16..24].try_into().unwrap());
|
||||
Some((from_uid, to_uid, msg_id))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use sled::Db;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const SEQUENCE_BANDWIDTH: i64 = 32;
|
||||
|
||||
pub(crate) struct Sequence {
|
||||
ty: u8,
|
||||
next: i64,
|
||||
leased: i64,
|
||||
}
|
||||
|
||||
impl Sequence {
|
||||
pub(crate) fn new(db: &Db, ty: u8) -> Result<Sequence> {
|
||||
let (next, leased) = Self::update_sequence_lease(ty, db)?;
|
||||
Ok(Sequence { ty, next, leased })
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self, db: &Db) {
|
||||
let _ = db.insert(key_sequence(self.ty), &self.next.to_be_bytes());
|
||||
}
|
||||
|
||||
fn update_sequence_lease(ty: u8, db: &Db) -> Result<(i64, i64)> {
|
||||
let key = key_sequence(ty);
|
||||
let next = match db.get(key)? {
|
||||
Some(value) => {
|
||||
i64::from_be_bytes(value.as_ref().try_into().map_err(|_| Error::InvalidData)?)
|
||||
}
|
||||
None => 1,
|
||||
};
|
||||
let leased = next + SEQUENCE_BANDWIDTH;
|
||||
db.insert(key, &leased.to_be_bytes())?;
|
||||
Ok((next, leased))
|
||||
}
|
||||
|
||||
pub(crate) fn generate_id(&mut self, db: &Db) -> Result<i64> {
|
||||
if self.next >= self.leased {
|
||||
let (next, lease) = Self::update_sequence_lease(self.ty, db)?;
|
||||
self.next = next;
|
||||
self.leased = lease;
|
||||
}
|
||||
let val = self.next;
|
||||
self.next += 1;
|
||||
Ok(val)
|
||||
}
|
||||
}
|
||||
|
||||
fn key_sequence(ty: u8) -> [u8; 10] {
|
||||
let mut data = [0; 10];
|
||||
data[0..9].copy_from_slice(b"SEQUENCE/");
|
||||
data[9] = ty;
|
||||
data
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "open-graph"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
include = ["**/*.rs", "Cargo.toml"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "*", features = ["macros"] }
|
||||
html5ever = "0.22.5"
|
||||
reqwest = { version = "0.11.10", features = [
|
||||
"tokio-rustls",
|
||||
], default-features = false }
|
||||
serde = "1.0.136"
|
||||
serde_derive = "1.0.136"
|
||||
serde_json = "1.0.79"
|
||||
poem-openapi = { version = "2.0.23", optional = true }
|
||||
http = "0.2.6"
|
||||
url = "2.2.2"
|
||||
flate2 = "1.0"
|
||||
regex = "1.5.4"
|
||||
async-recursion = "1.0.0"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
poem_openapi = ["poem-openapi"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Open Graph Protocal Parser
|
||||
|
||||
`pvc-opengraph` is based on `opengraph-0.2.4`, added some extra information: `Title`, `Description`, `Favicon`, and solved some problem:
|
||||
|
||||
1. Support three-level attribute name.
|
||||
```html
|
||||
<meta property="og:image:height" content="1000" />
|
||||
```
|
||||
|
||||
2. Support extracting `favicon`.
|
||||
```html
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="https://domain.com/img/favicon/icon.png"/>
|
||||
```
|
||||
|
||||
3. Support extracting `Title`.
|
||||
```html
|
||||
<title>First Title</title>
|
||||
<meta property="og:title" content="High priority Title" />
|
||||
```
|
||||
|
||||
4. Relative path is automatically converted to absolute path:
|
||||
if request `https://domain.com/path1/path2` get the following:
|
||||
```html
|
||||
<meta property="og:image" content="rock.jpg" />
|
||||
```
|
||||
The absolute path is obtained after parsing:
|
||||
```
|
||||
https://domain.com/path1/rock.jpg
|
||||
```
|
||||
|
||||
5. `reqwest` updated to the latest version. The `edition` adopts 2021, and `rustls` is enabled by default, which supports better cross platform compilation.
|
||||
|
||||
|
||||
6. add feature `poem-openapi`.
|
||||
|
||||
|
||||
7. `unzip` decoding of non-standard servers is supported. few servers ignore the client `Accept-Encoding` and always return `gz` format data.
|
||||
|
||||
|
||||
9. The code passed the `cargo test` and `cargo clip`.
|
||||
@@ -0,0 +1,17 @@
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
#[cfg(feature = "poem_openapi")]
|
||||
#[derive(poem_openapi::Object)]
|
||||
pub struct Audio {
|
||||
pub r#type: Option<String>,
|
||||
pub url: String,
|
||||
pub secure_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(url: String) -> Audio {
|
||||
Audio {
|
||||
url,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::{
|
||||
error,
|
||||
fmt::{Display, Formatter, Result as FmtResult},
|
||||
};
|
||||
|
||||
use reqwest;
|
||||
use url;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
NetworkError(reqwest::Error),
|
||||
UrlParseError(url::ParseError),
|
||||
IoError(std::io::Error),
|
||||
Other(String),
|
||||
Unexpected,
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut Formatter) -> FmtResult {
|
||||
match self {
|
||||
Error::NetworkError(ref e) => write!(f, "NetworkError: {}", e),
|
||||
Error::UrlParseError(ref e) => write!(f, "UrlParseError: {}", e),
|
||||
Error::IoError(ref e) => write!(f, "IoError: {}", e),
|
||||
Error::Other(s) => write!(f, "Other Error: {}", s.as_str()),
|
||||
Error::Unexpected => write!(f, "UnexpectedError"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(err: reqwest::Error) -> Error {
|
||||
Error::NetworkError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for Error {
|
||||
fn from(err: url::ParseError) -> Error {
|
||||
Error::UrlParseError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Error {
|
||||
Error::IoError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for Error {
|
||||
fn description(&self) -> &str {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use html5ever::{
|
||||
parse_document,
|
||||
rcdom::{
|
||||
Handle,
|
||||
NodeData::{Comment, Doctype, Document, Element, ProcessingInstruction, Text},
|
||||
RcDom,
|
||||
},
|
||||
tendril::{fmt::Slice, TendrilSink},
|
||||
Attribute,
|
||||
};
|
||||
use http::HeaderMap;
|
||||
use reqwest;
|
||||
|
||||
// use std::io::Read;
|
||||
use crate::{error::Error, Audio, Image, Object, Video};
|
||||
|
||||
fn gz_decode(data: &[u8]) -> Result<String, Error> {
|
||||
use std::io::prelude::*;
|
||||
|
||||
use flate2::read::GzDecoder;
|
||||
let mut d = GzDecoder::new(data);
|
||||
let mut s = String::new();
|
||||
d.read_to_string(&mut s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
use regex::Regex;
|
||||
fn fetch_url_from_meta(text: &str) -> Option<String> {
|
||||
let re = Regex::new(r#"<meta .*?content=".*?url=(.*?)""#).unwrap();
|
||||
if let Some(cap) = re.captures(text) {
|
||||
return cap.get(1).map(|v| v.as_str().to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fetch_url_from_meta() {
|
||||
let text = r#"<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">"#;
|
||||
let a = fetch_url_from_meta(text);
|
||||
assert_eq!(Some("http://www.baidu.com/".to_string()), a);
|
||||
}
|
||||
|
||||
use async_recursion::async_recursion;
|
||||
#[async_recursion]
|
||||
pub async fn fetch(url: &str, header_map: Option<HeaderMap>, deep: u8) -> Result<Object, Error> {
|
||||
// let body = reqwest::get(url).await?.text().await?;
|
||||
let mut client = reqwest::Client::new().get(url);
|
||||
if let Some(header_map2) = header_map.clone() {
|
||||
client = client.headers(header_map2);
|
||||
client = client.header(http::header::ACCEPT_ENCODING, "none");
|
||||
}
|
||||
let body = client.send().await?.bytes().await?;
|
||||
// gz magic number: 1f 8b
|
||||
if body.len() < 2 {
|
||||
return Err(Error::Unexpected);
|
||||
}
|
||||
let body = if body[0] == 0x1f && body[1] == 0x8b {
|
||||
gz_decode(body.as_bytes())
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
} else {
|
||||
String::from_utf8(body.to_vec()).unwrap_or_default()
|
||||
};
|
||||
|
||||
if body.contains("<meta") {
|
||||
if let Some(url) = fetch_url_from_meta(body.as_str()) {
|
||||
if deep > 10 {
|
||||
return Err(Error::Other("too many redirect!".into()));
|
||||
}
|
||||
return fetch(&url, header_map, deep + 1).await;
|
||||
}
|
||||
}
|
||||
// let body = client.send().await?.text().await?;
|
||||
let dom = parse_document(RcDom::default(), Default::default())
|
||||
.from_utf8()
|
||||
.read_from(&mut body.as_bytes())
|
||||
.unwrap();
|
||||
walk(dom.document, url)
|
||||
}
|
||||
|
||||
fn attr_value(attr_name: &str, attrs: &[Attribute]) -> Option<String> {
|
||||
for attr in attrs.iter() {
|
||||
if attr.name.local.as_ref() == attr_name {
|
||||
return Some(attr.value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn inner_text(handle: Handle) -> Option<String> {
|
||||
if handle.children.borrow().len() > 0 {
|
||||
let text1 = handle.children.borrow();
|
||||
let inner = text1.get(0);
|
||||
if let Some(handle) = inner {
|
||||
if let Text { contents } = handle.data.borrow() {
|
||||
return Some(contents.borrow().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// use std::any::Any;
|
||||
fn walk(handle: Handle, url: &str) -> Result<Object, Error> {
|
||||
let mut key_key_values = vec![];
|
||||
do_walk(handle, &mut key_key_values, url)?;
|
||||
let mut obj = Object::default();
|
||||
for (key0, key1, value) in key_key_values {
|
||||
match key0.as_ref() {
|
||||
"title" => {
|
||||
if !value.is_empty() {
|
||||
obj.title = value;
|
||||
}
|
||||
}
|
||||
"type" => {
|
||||
obj.r#type = value;
|
||||
}
|
||||
"url" => {
|
||||
obj.url = value;
|
||||
}
|
||||
"favicon_url" => {
|
||||
obj.favicon_url = Some(value);
|
||||
}
|
||||
"description" => {
|
||||
if !value.is_empty() {
|
||||
obj.description = Some(value);
|
||||
}
|
||||
}
|
||||
"locale" => {
|
||||
obj.locale = Some(value);
|
||||
}
|
||||
"locale_alternate" => {
|
||||
// obj.locale_alternate.as_mut().map(|v|v.push(value)); // cargo clippy warning!
|
||||
if let Some(v) = obj.locale_alternate.as_mut() {
|
||||
v.push(value);
|
||||
}
|
||||
}
|
||||
"site_name" => {
|
||||
obj.site_name = Some(value);
|
||||
}
|
||||
"image" => {
|
||||
if key1.is_empty() {
|
||||
obj.images.push(Image::new(value));
|
||||
} else {
|
||||
if obj.images.is_empty() {
|
||||
obj.images.push(Image::new(value.clone()));
|
||||
}
|
||||
let mut v = obj.images.last_mut().unwrap();
|
||||
match key1.as_ref() {
|
||||
"width" => v.width = value.parse::<i32>().ok(),
|
||||
"height" => v.height = value.parse::<i32>().ok(),
|
||||
"secure_url" => v.secure_url = Some(value),
|
||||
"alt" => v.alt = Some(value),
|
||||
"type" => v.r#type = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"audio" => {
|
||||
if key1.is_empty() {
|
||||
obj.audios.push(Audio::new(value));
|
||||
} else {
|
||||
if obj.audios.is_empty() {
|
||||
obj.audios.push(Audio::new(value.clone()));
|
||||
}
|
||||
let mut v = obj.audios.last_mut().unwrap();
|
||||
match key1.as_ref() {
|
||||
"secure_url" => v.secure_url = Some(value),
|
||||
"type" => v.r#type = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"video" => {
|
||||
if key1.is_empty() {
|
||||
obj.videos.push(Video::new(value));
|
||||
} else {
|
||||
if obj.videos.is_empty() {
|
||||
obj.videos.push(Video::new(value.clone()));
|
||||
}
|
||||
let mut v = obj.videos.last_mut().unwrap();
|
||||
match key1.as_ref() {
|
||||
"height" => v.height = value.parse::<i32>().ok(),
|
||||
"width" => v.width = value.parse::<i32>().ok(),
|
||||
"secure_url" => v.secure_url = Some(value),
|
||||
"type" => v.r#type = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
Ok(obj)
|
||||
}
|
||||
fn do_walk(
|
||||
handle: Handle,
|
||||
key_key_values: &mut Vec<(String, String, String)>,
|
||||
url: &str,
|
||||
) -> Result<(), Error> {
|
||||
let abs_path = url::Url::parse(url)?;
|
||||
match handle.data {
|
||||
Document => (),
|
||||
Doctype { .. } => (),
|
||||
Text { .. } => (),
|
||||
Comment { .. } => (),
|
||||
Element {
|
||||
ref name,
|
||||
ref attrs,
|
||||
..
|
||||
} => {
|
||||
let tag_name = name.local.as_ref();
|
||||
match tag_name {
|
||||
"meta" => {
|
||||
if let Some(v) = attr_value("name", &attrs.borrow()) {
|
||||
if v == "description" {
|
||||
if let Some(vv) = attr_value("content", &attrs.borrow()) {
|
||||
key_key_values.push((
|
||||
"description".to_string(),
|
||||
"".to_string(),
|
||||
vv,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = attr_value("property", &attrs.borrow()) {
|
||||
if v.starts_with("og:") {
|
||||
let end = v.chars().count();
|
||||
let key = unsafe { v.get_unchecked(3..end) }.to_string();
|
||||
let mut keys: Vec<_> = key.split(':').collect();
|
||||
let key0 = keys.remove(0).to_string();
|
||||
let key1 = if keys.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
keys.remove(0).to_string()
|
||||
};
|
||||
if let Some(mut vv) = attr_value("content", &attrs.borrow()) {
|
||||
if (key0 == "image" || key0 == "video" || key0 == "audio")
|
||||
&& key1.is_empty()
|
||||
&& !vv.starts_with("http")
|
||||
{
|
||||
vv = abs_path.join(vv.as_str())?.to_string();
|
||||
}
|
||||
key_key_values.push((key0, key1, vv));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"title" => {
|
||||
let title = inner_text(handle.clone()).unwrap_or_default();
|
||||
key_key_values.push(("title".to_string(), "".to_string(), title))
|
||||
}
|
||||
// <link rel="apple-touch-icon" sizes="57x57" href="https://www.redditstatic.com/desktop2x/img/favicon/apple-icon-57x57.png"/>
|
||||
// <link rel="apple-touch-icon" sizes="60x60" href="https://www.redditstatic.com/desktop2x/img/favicon/apple-icon-60x60.png"/>
|
||||
// <link rel="icon" type="image/png" href="http://example.com/myicon.png">
|
||||
// <link rel="icon" type="image/x-icon" href="/images/favicon.ico">
|
||||
"link" => {
|
||||
if let Some(v) = attr_value("rel", &attrs.borrow()) {
|
||||
if v.ends_with("icon") {
|
||||
if let Some(vv) = attr_value("href", &attrs.borrow()) {
|
||||
let vv = url::Url::parse(url)?.join(&vv)?;
|
||||
key_key_values.push((
|
||||
"favicon_url".to_string(),
|
||||
"".to_string(),
|
||||
vv.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
ProcessingInstruction { .. } => unreachable!(),
|
||||
}
|
||||
for child in handle.children.borrow().iter() {
|
||||
do_walk(child.clone(), key_key_values, url)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_open_graph_object() {
|
||||
let x = r#"
|
||||
<html prefix="og: http://ogp.me/ns#">
|
||||
<head>
|
||||
<title>The Rock 123</title>
|
||||
<meta name="description" content="Some Description" />
|
||||
<meta property="og:title" content="The Rock" />
|
||||
<meta property="og:type" content="video.movie" />
|
||||
<meta property="og:url" content="http://www.imdb.com/title/tt0117500/" />
|
||||
<meta property="og:image" content="http://ia.media-imdb.com/images/rock.jpg" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image" content="https://example.com/rock.jpg" />
|
||||
<meta property="og:image:width" content="400" />
|
||||
<meta property="og:image:height" content="300" />
|
||||
<meta property="og:image" content="https://example.com/rock2.jpg" />
|
||||
<meta property="og:image" content="https://example.com/rock3.jpg" />
|
||||
<meta property="og:image:height" content="1000" />
|
||||
</head>
|
||||
<body>
|
||||
<p>hello</p>
|
||||
<h1>hello</h1>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
let x = x.to_string();
|
||||
let dom = parse_document(RcDom::default(), Default::default())
|
||||
.from_utf8()
|
||||
.read_from(&mut x.as_bytes())
|
||||
.unwrap();
|
||||
let obj = walk(dom.document, "").unwrap();
|
||||
assert_eq!(obj.title, "The Rock".to_string());
|
||||
assert_eq!(obj.description, Some("Some Description".to_string()));
|
||||
assert_eq!(obj.r#type, "video.movie".to_string());
|
||||
assert_eq!(obj.url, "http://www.imdb.com/title/tt0117500/".to_string());
|
||||
assert_eq!(obj.images.len(), 4);
|
||||
assert_eq!(
|
||||
obj.images[1].url,
|
||||
"https://example.com/rock.jpg".to_string()
|
||||
);
|
||||
assert_eq!(obj.images[1].width, Some(400));
|
||||
assert_eq!(obj.images[1].height, Some(300));
|
||||
assert_eq!(obj.images[3].height, Some(1000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch() {
|
||||
// curl -H "accept-encoding: none" https://www.bilibili.com/video/BV1s3411J7Aj
|
||||
// let obj = fetch("https://www.bilibili.com/video/BV1s3411J7Aj", None).await.unwrap();
|
||||
// let obj = fetch("https://www.youtube.com/watch?v=BlI9PVQA8ZA", None).await.unwrap();
|
||||
// let obj = fetch("https://dapr.io/", None).await.unwrap();
|
||||
// dbg!(obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// more media types: https://en.wikipedia.org/wiki/Media_type
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
#[cfg(feature = "poem_openapi")]
|
||||
#[derive(poem_openapi::Object)]
|
||||
pub struct Image {
|
||||
pub r#type: Option<String>,
|
||||
pub url: String,
|
||||
pub secure_url: Option<String>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub alt: Option<String>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub fn new(url: String) -> Self {
|
||||
Image {
|
||||
url,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// https://ogp.me/
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
extern crate html5ever;
|
||||
extern crate reqwest;
|
||||
extern crate serde;
|
||||
extern crate serde_json;
|
||||
|
||||
mod audio;
|
||||
mod image;
|
||||
mod object;
|
||||
mod video;
|
||||
|
||||
pub mod error;
|
||||
pub mod fetcher;
|
||||
|
||||
pub use audio::Audio;
|
||||
pub use error::Error;
|
||||
pub use fetcher::fetch;
|
||||
pub use image::Image;
|
||||
pub use object::Object;
|
||||
pub use video::Video;
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::{Audio, Image, Video};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
#[cfg(feature = "poem_openapi")]
|
||||
#[derive(poem_openapi::Object)]
|
||||
pub struct Object {
|
||||
pub r#type: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
|
||||
pub images: Vec<Image>,
|
||||
pub audios: Vec<Audio>,
|
||||
pub videos: Vec<Video>,
|
||||
|
||||
pub favicon_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub locale_alternate: Option<Vec<String>>,
|
||||
pub site_name: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
#[cfg(feature = "poem_openapi")]
|
||||
#[derive(poem_openapi::Object)]
|
||||
pub struct Video {
|
||||
pub r#type: Option<String>,
|
||||
pub url: String,
|
||||
pub secure_url: Option<String>,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
impl Video {
|
||||
pub fn new(url: String) -> Video {
|
||||
Video {
|
||||
url,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "rc-token"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
hmac = "0.11.0"
|
||||
jwt = "0.15.0"
|
||||
serde = { version = "1.0.132", features = ["derive"] }
|
||||
sha2 = "0.9.0"
|
||||
textnonce = "1.0.0"
|
||||
thiserror = "1.0.30"
|
||||
@@ -0,0 +1,8 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Jwt(#[from] jwt::Error),
|
||||
|
||||
#[error("expired")]
|
||||
Expired,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
mod error;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
pub use error::Error;
|
||||
use hmac::{Hmac, NewMac};
|
||||
use jwt::{SignWithKey, VerifyWithKey};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub enum TokenType {
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TokenFields<T> {
|
||||
#[serde(rename = "d")]
|
||||
data: T,
|
||||
#[serde(rename = "e")]
|
||||
expire_at: DateTime<Utc>,
|
||||
#[serde(rename = "n")]
|
||||
nonce: String,
|
||||
#[serde(rename = "t")]
|
||||
token_type: TokenType,
|
||||
}
|
||||
|
||||
pub fn create_token_pair(
|
||||
server_key: &str,
|
||||
data: impl Serialize,
|
||||
refresh_token_expiry_seconds: i64,
|
||||
token_expiry_seconds: i64,
|
||||
) -> Result<(String, String), Error> {
|
||||
let refresh_token = create_token(
|
||||
server_key,
|
||||
&data,
|
||||
TokenType::RefreshToken,
|
||||
refresh_token_expiry_seconds,
|
||||
)?;
|
||||
let token = create_token(
|
||||
server_key,
|
||||
&data,
|
||||
TokenType::AccessToken,
|
||||
token_expiry_seconds,
|
||||
)?;
|
||||
Ok((refresh_token, token))
|
||||
}
|
||||
|
||||
fn create_token(
|
||||
server_key: &str,
|
||||
data: &impl Serialize,
|
||||
token_type: TokenType,
|
||||
expiry_seconds: i64,
|
||||
) -> Result<String, Error> {
|
||||
Ok(TokenFields {
|
||||
data,
|
||||
expire_at: Utc::now() + Duration::seconds(expiry_seconds),
|
||||
nonce: textnonce::TextNonce::sized(16).unwrap().to_string(),
|
||||
token_type,
|
||||
}
|
||||
.sign_with_key(&create_hmac_key(server_key))?)
|
||||
}
|
||||
|
||||
fn create_hmac_key(server_key: &str) -> Hmac<Sha256> {
|
||||
Hmac::<Sha256>::new_from_slice(server_key.as_bytes()).expect("invalid server key")
|
||||
}
|
||||
|
||||
pub fn parse_token<T: DeserializeOwned>(
|
||||
server_key: &str,
|
||||
token: &str,
|
||||
check_expired: bool,
|
||||
) -> Result<(TokenType, T), Error> {
|
||||
let fields =
|
||||
VerifyWithKey::<TokenFields<T>>::verify_with_key(token, &create_hmac_key(server_key))?;
|
||||
if check_expired && fields.expire_at < Utc::now() {
|
||||
return Err(Error::Expired);
|
||||
}
|
||||
Ok((fields.token_type, fields.data))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_token() {
|
||||
let (refresh_token, token) = create_token_pair("123456", 100i32, 60 * 5, 60).unwrap();
|
||||
|
||||
let (token_type, value) = parse_token::<i32>("123456", &token, true).unwrap();
|
||||
assert_eq!(token_type, TokenType::AccessToken);
|
||||
assert_eq!(value, 100);
|
||||
|
||||
let (token_type, value) = parse_token::<i32>("123456", &refresh_token, true).unwrap();
|
||||
assert_eq!(token_type, TokenType::RefreshToken);
|
||||
assert_eq!(value, 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "vc-license"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
chrono={version="0.4.19"}
|
||||
uuid = {version="1.1.2", features=["v4"]}
|
||||
rsa = {version="0.6.1", features=["pem"]}
|
||||
pkcs1="0.3.3"
|
||||
rand="0.8.5"
|
||||
sha2="0.9.9"
|
||||
digest="0.8.1"
|
||||
bs58="0.4.0"
|
||||
hex="0.4.3"
|
||||
anyhow = "1.0.52"
|
||||
@@ -0,0 +1,265 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use pkcs1::EncodeRsaPrivateKey;
|
||||
use rsa::{
|
||||
pkcs1::{EncodeRsaPublicKey, LineEnding},
|
||||
PaddingScheme, PublicKey, RsaPrivateKey, RsaPublicKey,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct License {
|
||||
pub domains: Vec<String>,
|
||||
pub user_limit: u32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expired_at: DateTime<Utc>,
|
||||
pub sign: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for License {
|
||||
fn default() -> Self {
|
||||
License {
|
||||
domains: vec![],
|
||||
user_limit: 0,
|
||||
created_at: DateTime::<Utc>::MIN_UTC,
|
||||
expired_at: DateTime::<Utc>::MIN_UTC,
|
||||
sign: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl License {
|
||||
pub fn encode(&self) -> String {
|
||||
format!(
|
||||
"{},{},{}",
|
||||
self.domains.join("|"),
|
||||
self.created_at,
|
||||
self.expired_at
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::inherent_to_string)]
|
||||
pub fn to_string(&self) -> String {
|
||||
let data = format!(
|
||||
"{},{},{},{},{}",
|
||||
self.domains.join("|"),
|
||||
self.user_limit,
|
||||
self.created_at.to_rfc3339(),
|
||||
self.expired_at.to_rfc3339(),
|
||||
hex::encode(&self.sign)
|
||||
);
|
||||
bs58::encode(data.as_bytes()).into_string()
|
||||
}
|
||||
|
||||
pub fn from_string(s: String) -> Result<Self> {
|
||||
let a = bs58::decode(&s).into_vec()?;
|
||||
let b = String::from_utf8(a)?;
|
||||
let arr = b.split(',').collect::<Vec<_>>();
|
||||
if arr.len() < 5 {
|
||||
return Err(anyhow!("Bad Data: {}", &s));
|
||||
}
|
||||
|
||||
let user_limit = arr[1].parse::<u32>().unwrap_or_default();
|
||||
let created_at = DateTime::parse_from_rfc3339(arr[2])?;
|
||||
let expired_at = DateTime::parse_from_rfc3339(arr[3])?;
|
||||
let created_at = DateTime::<Utc>::from_utc(created_at.naive_utc(), Utc);
|
||||
let expired_at = DateTime::<Utc>::from_utc(expired_at.naive_utc(), Utc);
|
||||
|
||||
let sign = hex::decode(arr[4])?;
|
||||
Ok(License {
|
||||
domains: arr[0].split('|').map(|v| v.to_string()).collect::<Vec<_>>(),
|
||||
user_limit,
|
||||
created_at,
|
||||
expired_at,
|
||||
sign,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LicenseGenerator {
|
||||
private_key: RsaPrivateKey,
|
||||
public_key: RsaPublicKey,
|
||||
}
|
||||
|
||||
// generate new
|
||||
impl LicenseGenerator {
|
||||
pub fn new(private_key: RsaPrivateKey, public_key: RsaPublicKey) -> Self {
|
||||
LicenseGenerator {
|
||||
private_key,
|
||||
public_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_pem(private_key_pem: &str, public_key_pem: &str) -> Result<Self> {
|
||||
let private_key = pkcs1::DecodeRsaPrivateKey::from_pkcs1_pem(private_key_pem)?;
|
||||
let public_key = pkcs1::DecodeRsaPublicKey::from_pkcs1_pem(public_key_pem)?;
|
||||
Ok(LicenseGenerator {
|
||||
private_key,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn gen(&self, domains: &str, expired_at: DateTime<Utc>, user_limit: u32) -> License {
|
||||
let created_at = Utc::now();
|
||||
let mut license = License {
|
||||
domains: domains
|
||||
.split('|')
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
user_limit,
|
||||
created_at,
|
||||
expired_at,
|
||||
sign: vec![],
|
||||
};
|
||||
let data = license.encode();
|
||||
let sign = rsa_sign(data.as_bytes(), &self.private_key);
|
||||
license.sign = sign;
|
||||
license
|
||||
}
|
||||
|
||||
pub fn check(&self, license_str: &str) -> Result<()> {
|
||||
let license = License::from_string(license_str.to_string())?;
|
||||
if license.expired_at < Utc::now() {
|
||||
return Err(anyhow!("License expired at {}!", license.expired_at));
|
||||
}
|
||||
let data = license.encode();
|
||||
if rsa_check_sign(data.as_bytes(), &license.sign, &self.public_key).is_err() {
|
||||
return Err(anyhow!("Invalid sign!"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gen_rsa_pair() -> (RsaPrivateKey, RsaPublicKey) {
|
||||
let mut rng = rand::thread_rng();
|
||||
let bits = 2048;
|
||||
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key");
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
(private_key, public_key)
|
||||
}
|
||||
|
||||
pub fn gen_rsa_pem_pair() -> Result<(String, String)> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let bits = 2048;
|
||||
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key");
|
||||
let a = private_key.to_pkcs1_pem(LineEnding::CRLF)?.to_string();
|
||||
let public_key = RsaPublicKey::from(&private_key);
|
||||
let b = public_key.to_pkcs1_pem(LineEnding::CRLF)?;
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
fn rsa_sign(digest_in: &[u8], private_key: &RsaPrivateKey) -> Vec<u8> {
|
||||
let padding = PaddingScheme::new_pkcs1v15_sign(None);
|
||||
private_key.sign(padding, digest_in).unwrap()
|
||||
}
|
||||
|
||||
fn rsa_check_sign(hashed: &[u8], sig: &[u8], public_key: &RsaPublicKey) -> Result<()> {
|
||||
Ok(public_key.verify(PaddingScheme::new_pkcs1v15_sign(None), hashed, sig)?)
|
||||
}
|
||||
|
||||
pub fn rsa_check_license_bs58(license_bs58: &str, public_key_pem: &str) -> Result<()> {
|
||||
let public_key: RsaPublicKey = pkcs1::DecodeRsaPublicKey::from_pkcs1_pem(public_key_pem)?;
|
||||
let license = License::from_string(license_bs58.to_string())?;
|
||||
let data = license.encode();
|
||||
rsa_check_sign(data.as_bytes(), license.sign.as_slice(), &public_key)
|
||||
}
|
||||
|
||||
pub fn rsa_check_license(license: &License, public_key_pem: &str) -> Result<()> {
|
||||
let public_key: RsaPublicKey = pkcs1::DecodeRsaPublicKey::from_pkcs1_pem(public_key_pem)?;
|
||||
let data = license.encode();
|
||||
rsa_check_sign(data.as_bytes(), license.sign.as_slice(), &public_key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::ops::Add;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_license() {
|
||||
let (private_key, public_key) = gen_rsa_pair();
|
||||
let licensegen = LicenseGenerator::new(private_key, public_key);
|
||||
let expired_at = Utc::now().add(chrono::Duration::seconds(365 * 86400));
|
||||
let license = licensegen.gen("www.domain.com|www.domain2.com", expired_at, 10);
|
||||
assert_eq!(license.domains[0], "www.domain.com");
|
||||
assert_eq!(license.domains[1], "www.domain2.com");
|
||||
assert_eq!(license.expired_at, expired_at);
|
||||
let b = licensegen.check(&license.to_string());
|
||||
assert!(b.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_license() {
|
||||
let (private_key, public_key) = gen_rsa_pair();
|
||||
let licensegen = LicenseGenerator::new(private_key, public_key.clone());
|
||||
let expired_at = Utc::now().add(chrono::Duration::seconds(365 * 86400));
|
||||
let license = licensegen.gen("www.domain.com|www.domain2.com", expired_at, 20);
|
||||
|
||||
let license_bs58 = license.to_string();
|
||||
|
||||
let public_key_pem = public_key.to_pkcs1_pem(LineEnding::CRLF).unwrap();
|
||||
let a = rsa_check_license_bs58(&license_bs58, &public_key_pem);
|
||||
assert!(a.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gen_license() {
|
||||
let private_key_pem = r#"-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEApqGLPAiVzx42qRkjDGqCT4+BrS3BReJA7UAXQt3YNfw2HIB+
|
||||
CJSDF22KnpqmnsaLWmxrUP1Q+ttb+fZhMZ569s5ZLs9h6pq2oTBK8kBUKz127rpw
|
||||
HSpGVnuGbkPB4NUcTOYiDTLT7iD9NSN38Cr1ITTD3+4EiSiCuf9aUpggfo06fqF6
|
||||
9ebDC0pPSTRvIDgKrJiku93c3d1uDq1DWfYKu3GP23ie5+3WwQcsd/XG/0xyMk1h
|
||||
fVQJqTf5Z2rVdmhVGt0XjV6cmaVshJOxGeoAubPLJX4G4DLTvXKGy/WlQlQTqIBz
|
||||
8xUBdnwtOymXGQpaS/Vfo0q1kGzZoXsCx3v7BQIDAQABAoIBAQClK0fakB//F9HS
|
||||
uCn3qrPUrUk7IjmMTgNRqM/l5gTlLkVs5ykG1D9FP73CDUXP6LpFPWb69r4IituW
|
||||
4FPjXVZBwrTAI6zJYeZZzIbdrkpAOkLjzEZJgpgKLZNJRTyu4k+VIiDquFE+n9Cd
|
||||
lbTTiaFF8wmdtE8mrdM3Dxi2+jhrd3Snp3kVvFniV7EQTwC6SEOQkyMP01aQgc1B
|
||||
ydxxZFqEO2oQ/uBdjovLEy6rAo18d1EZ5p2j/75oIPfVvtTELlnqLen/FKBmfaxz
|
||||
vHmaALJahAa1LYnqpr4Mn+SOmkf3lgtfz0H0yneN+9GfTNYcBePReOkcXFmkpE4p
|
||||
DG0J8QxBAoGBANX5Z8+wxrhHBcEPXS9/y/EWrLyM37HYoxUPfROgPrxACwxMdlIN
|
||||
QPtnGPdwW6qstIL8D63RCwHKADhUGd4BHnUyTiWjW0fQP3lJDj5HStU6/Iptec7t
|
||||
ZHggSSqPfhy5xQvRKVIxEDOYMEc4p7O/Lgocst8fK2g1USkT0mD3pPgNAoGBAMdb
|
||||
un9jhsV8O3ZufTrANF7IPAQqxk9i7A+zQM7DDXG1zYg/HDWWEh8OHTy8sOz7bbDt
|
||||
XpfhWqLxCldJZ5OkeIx27lejGbV7Fkmr7IyoEXVlM6pz4zbaKG7g/YC3xSoGyg03
|
||||
ijJ9fLhltCIeKW+df/lNFqeehwv3gGeSq0epVpjZAoGACzSMYyv2vB+8BWgwkRQ4
|
||||
Md/mG9mkvUODBs9Q1X5GysTvzy0R5SochQ3ZGNwhcMaqjVF14LxZvzY83LZKxH16
|
||||
gtinjwEG/rPBHzDcNha1rITyRK2G+3cjE8ddDYWGLSrtTrkdWNiI6KrHnHMzFQ6l
|
||||
8pGeLGENfN+N6IDJO5q8YOECgYAGkICtnStc6WBT4AODobyXumQvhvEMwCchxTdH
|
||||
F6kjq2bfK6TUJuLl3uMbkuMIiqbsAoTw31zKrME4apRcijfl+CyU+ivoi+sJ9f1O
|
||||
DGK2yORQooxCzCA0tnfieyqk3aBdmwyT6QnoUIED9pZKtJb4MI+kaVXtEPNLdcrq
|
||||
Cyts0QKBgQCJY+aRSa7x/WYjotQmwZDfMmABqUTjxWInv2VC6TID4qTR//5ZLWGD
|
||||
PGaAcyhCpEW7oh2++dvgy2L5ERToqfbKt0G7gFVdg5fMNExG5xKB1Z8BAe2JrVO7
|
||||
3HPF/Sk36ickz/3zXaBWSnoZUqzrMlJeXoSyLYFC+opQKatRRP5vQw==
|
||||
-----END RSA PRIVATE KEY-----"#;
|
||||
|
||||
let public_key_pem = r#"-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEApqGLPAiVzx42qRkjDGqCT4+BrS3BReJA7UAXQt3YNfw2HIB+CJSD
|
||||
F22KnpqmnsaLWmxrUP1Q+ttb+fZhMZ569s5ZLs9h6pq2oTBK8kBUKz127rpwHSpG
|
||||
VnuGbkPB4NUcTOYiDTLT7iD9NSN38Cr1ITTD3+4EiSiCuf9aUpggfo06fqF69ebD
|
||||
C0pPSTRvIDgKrJiku93c3d1uDq1DWfYKu3GP23ie5+3WwQcsd/XG/0xyMk1hfVQJ
|
||||
qTf5Z2rVdmhVGt0XjV6cmaVshJOxGeoAubPLJX4G4DLTvXKGy/WlQlQTqIBz8xUB
|
||||
dnwtOymXGQpaS/Vfo0q1kGzZoXsCx3v7BQIDAQAB
|
||||
-----END RSA PUBLIC KEY-----"#;
|
||||
|
||||
let licensegen = LicenseGenerator::new_from_pem(private_key_pem, public_key_pem).unwrap();
|
||||
|
||||
let expired_at = chrono::NaiveDate::parse_from_str("2025-01-01", "%Y-%m-%d")
|
||||
.expect("Date format error: %Y-%m-%d, just like: 2024-02-01");
|
||||
let expired_at = chrono::NaiveDateTime::new(
|
||||
expired_at,
|
||||
chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
|
||||
);
|
||||
let expired_at = chrono::DateTime::<Utc>::from_utc(expired_at, Utc);
|
||||
let license = licensegen.gen("www.domain.com|www.domain2.com", expired_at, 20);
|
||||
|
||||
let license_bs58 = license.to_string();
|
||||
// dbg!(license_bs58);
|
||||
// assert_eq!(&license_bs58,
|
||||
// "2RvN4krfyQbvuLcXzz4PwoJfckRFptqLKKXSP7f4Np7AGqgixrfdUzZ5qk2iT2gFwCntivyYnSaumyao7QF2SfQ3TKyLAgGDYeYeJcbBri9re5esG2PMrxZBjq68eR94yYqdTg6LAP9oc5WtLAaBX25RkVu2zt59kdgRzk5CbnnmZnMAHgZEDnsVJfCQa6HKnb3p1cpZa6LTANrKh1VuvCAerdCHCoc1YHNwUipg5JJXmxJQadFShv1sYREUHHzLuPMb8xHb7GkPJ6MJpQBzHfDnTySRG7BGxNz5GKCutFp1o7YjkQqZvKBTvWQ8ic4HmbHErLHw4aJ5CAMyugX4v2GAgxS8Z4zi9tjdzRtbbqzncnXSNBTumBxobBskNAbEaQC9HgBavgcAw4wHrHSyG3v2rTdsDUJZgTtanEfxnxZhSpKtXZRFzVNdjmo66GeLhvWwMZSXKzH89uTvMcEokmbUyLz9mKXzRP9dhTJ4bC6YWNrbDueYX9pqrsmXm3Z4aYP7DjknXwPCMKZbsCXZi2YQVUjggCyRarR4eThY6gZ8iWGkvi1ybAADooh8KXSAFNGSRRTGC5La6Atug7y6e6QnFmaRndLHdCyxqyq9LM1Ly2icYAFa2ZspXyL3MyBTLFvgQeqTmL1KQVHzhjwtkTvcFsUxScYNXhVgCkyD5vkSZpcwixGJYyYtkrF27XpMcq8mR6dAyi3Zqt2w68X3xA748a8ofky1KYwHmA1U4BaDpXbbVKSu6wtonMhzvQ6xMssyWJVhrzeymPnMwM8Xiut3pZcpC77Ri"
|
||||
// );
|
||||
|
||||
let license = License::from_string(license_bs58).unwrap();
|
||||
// dbg!(license.domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[1212/222951.272:ERROR:registration_protocol_win.cc(107)] CreateFile: 系统找不到指定的文件。 (0x2)
|
||||
[1212/222951.453:ERROR:registration_protocol_win.cc(107)] CreateFile: 系统找不到指定的文件。 (0x2)
|
||||
@@ -0,0 +1,199 @@
|
||||
The server code is under Big Time Public License license, and the official image is under Creative Commons Attribution-NonCommercial 4.0 International license.
|
||||
|
||||
Big Time Public License
|
||||
Version 2.0.0
|
||||
|
||||
https://bigtimelicense.com/versions/2.0.0
|
||||
|
||||
Purpose
|
||||
These terms let you use and share this software for noncommercial purposes and in small business for free, while also guaranteeing that paid licenses for big businesses will be available on fair, reasonable, and nondiscriminatory terms.
|
||||
|
||||
Note that this section now includes the full phrase “fair, reasonable, and nondiscriminatory”. It’s a FRAND commitment, and it says so.
|
||||
|
||||
Acceptance
|
||||
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||
|
||||
Noncommercial Purposes
|
||||
You may use the software for any noncommercial purpose.
|
||||
|
||||
Personal Uses
|
||||
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, count as use for noncommercial purposes.
|
||||
|
||||
Noncommercial Organizations
|
||||
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution counts as use for noncommercial purposes, regardless of the source of funding or obligations resulting from the funding.
|
||||
|
||||
Small Business
|
||||
You may use the software for the benefit of your company if it meets all these criteria:
|
||||
|
||||
had fewer than 20 total individuals working as employees and independent contractors at all times during the last tax year
|
||||
We’ve reduced this threshold from 100 to 20 individuals.
|
||||
|
||||
earned less than 1,000,000 USD (2019) total revenue in the last tax year
|
||||
|
||||
received less than 1,000,000 USD (2019) total debt, equity, and other investment in the last five tax years, counting investment in predecessor companies that reorganized into, merged with, or spun out your company
|
||||
|
||||
Adjust these dollar figures for inflation according to the United States Bureau of Labor Statistics’ consumer price index for all urban consumers, United States city average, for all items, not seasonally adjusted, with 1982–1984=100 reference base.
|
||||
|
||||
Big Business
|
||||
You may use the software for the benefit of your company:
|
||||
|
||||
for 128 days after your company stops qualifying under Small Business
|
||||
|
||||
indefinitely, if the licensor or their legal successor does not offer fair, reasonable, and nondiscriminatory terms for a commercial license for the software within 32 days of written request and negotiate in good faith to conclude a deal
|
||||
|
||||
Note the addition of an obligation to negotiate in good faith. No sending a proposal and then failing to follow through.
|
||||
|
||||
How to Request
|
||||
If this software includes an address for the licensor or an agent of the licensor in a standard place, such as in documentation, software package metadata, or an “about” page or screen, try to request a fair commercial license at that address. If this package includes both online and offline addresses, try online before offline. If you can’t deliver a request that way, or this software doesn’t include any addressees, spend one hour online researching an address, recording all your searches and inquiries as you go, and try any addresses that you find. If you can’t find any addresses, or if those addresses also fail, that counts as failure to offer a fair commercial license by the licensor under Big Business.
|
||||
|
||||
Fair, Reasonable, and Nondiscriminatory Terms
|
||||
Fair, reasonable, and nondiscriminatory terms may license the software perpetually or for a term, and may or may not cover new versions of the software. If the licensor advertises license terms and a pricing structure for generally available commercial licenses, the licensor proposes license terms and a price as advertised, and a customer not affiliated with the licensor has bought a commercial commercial license for the software on substantially equivalent terms in the past year, the proposal is fair, reasonable, and nondiscriminatory.
|
||||
|
||||
The clarifications around FRAND now live in this section, instead of definitions squirreled away at the bottom of the license.
|
||||
|
||||
The “as-advertised” safe harbor now extends to license terms as well as pricing. We’ve almost made more clear that the proposal needs to be in line with what’s advertised to qualify.
|
||||
|
||||
The clarifications around perpetual or not and with-updates or not remain.
|
||||
|
||||
Copyright License
|
||||
The licensor grants you a copyright license to do everything with the software that would otherwise infringe the licensor’s copyright in it for any purpose allowed by these terms.
|
||||
|
||||
Notices
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with Required Notice: that the licensor provided with the software. For example:
|
||||
|
||||
Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
Patent License
|
||||
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||
|
||||
Fair Use
|
||||
You may have “fair use” rights for the software under the law. These terms do not limit them.
|
||||
|
||||
No Other Rights
|
||||
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||
|
||||
Patent Defense
|
||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||
|
||||
Violations
|
||||
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||
|
||||
No Liability
|
||||
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.
|
||||
|
||||
Definitions
|
||||
The licensor is the individual or entity offering these terms, and the software is the software the licensor makes available under these terms.
|
||||
|
||||
You refers to the individual or entity agreeing to these terms.
|
||||
|
||||
Your company is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. Control means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
Your licenses are all the licenses granted to you for the software under these terms.
|
||||
|
||||
Use means anything you do with the software requiring one of your licenses.
|
||||
|
||||
Attribution-NonCommercial 4.0 International (Offical Images)
|
||||
|
||||
Official translations of this license are available in other languages.
|
||||
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
|
||||
|
||||
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
|
||||
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public License
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Section 1 – Definitions.
|
||||
|
||||
Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
|
||||
Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
|
||||
Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||
Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
||||
Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
||||
Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
||||
Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
||||
Licensor means the individual(s) or entity(ies) granting rights under this Public License.
|
||||
NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
|
||||
Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
||||
Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
||||
You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
|
||||
Section 2 – Scope.
|
||||
|
||||
License grant.
|
||||
Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
|
||||
reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
|
||||
produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
|
||||
Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
|
||||
Term. The term of this Public License is specified in Section 6(a).
|
||||
Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
|
||||
Downstream recipients.
|
||||
Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
|
||||
No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
||||
No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
|
||||
Other rights.
|
||||
|
||||
Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
|
||||
Patent and trademark rights are not licensed under this Public License.
|
||||
To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes.
|
||||
Section 3 – License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
|
||||
|
||||
Attribution.
|
||||
|
||||
If You Share the Licensed Material (including in modified form), You must:
|
||||
|
||||
retain the following if it is supplied by the Licensor with the Licensed Material:
|
||||
identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
|
||||
a copyright notice;
|
||||
a notice that refers to this Public License;
|
||||
a notice that refers to the disclaimer of warranties;
|
||||
a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
|
||||
indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
|
||||
indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
|
||||
You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
|
||||
If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
|
||||
If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
|
||||
Section 4 – Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
|
||||
|
||||
for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
|
||||
if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
|
||||
You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
|
||||
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
|
||||
Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
|
||||
To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
|
||||
The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
|
||||
Section 6 – Term and Termination.
|
||||
|
||||
This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
|
||||
Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||
|
||||
automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||
upon express reinstatement by the Licensor.
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||
Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
Section 7 – Other Terms and Conditions.
|
||||
|
||||
The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
|
||||
Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
|
||||
Section 8 – Interpretation.
|
||||
|
||||
For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
|
||||
To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
|
||||
No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
|
||||
Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
|
||||
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” The text of the Creative Commons public licenses is dedicated to the public domain under the CC0 Public Domain Dedication. Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
|
||||
Additional languages available: العربية, čeština, Deutsch, Ελληνικά, Español, euskara, suomeksi, français, hrvatski, Bahasa Indonesia, italiano, 日本語, 한국어, Lietuvių, latviski, te reo Māori, Nederlands, norsk, polski, português, română, русский, Slovenščina, svenska, Türkçe, українська, 中文, 華語. Please visit https://creativecommons.org/licenses/by-nc/4.0/legalcode for more information about official translations.
|
||||
@@ -0,0 +1,82 @@
|
||||
create table user
|
||||
(
|
||||
uid integer primary key autoincrement not null,
|
||||
name text collate nocase not null,
|
||||
password text,
|
||||
email text collate nocase,
|
||||
gender integer not null,
|
||||
language text not null,
|
||||
is_admin boolean not null default false,
|
||||
create_by text not null,
|
||||
avatar_updated_at timestamp not null default '1970-01-01 00:00:00',
|
||||
created_at timestamp not null default current_timestamp,
|
||||
updated_at timestamp not null default current_timestamp
|
||||
);
|
||||
|
||||
create unique index user_name on user (name);
|
||||
create unique index user_email on user (email);
|
||||
|
||||
create table google_auth
|
||||
(
|
||||
email text primary key not null,
|
||||
uid integer not null,
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index google_auth_uid on google_auth (uid);
|
||||
|
||||
create table openid_connect
|
||||
(
|
||||
issuer text not null,
|
||||
subject text not null,
|
||||
uid integer not null,
|
||||
primary key (issuer, subject),
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index openid_connect_uid on openid_connect (uid);
|
||||
|
||||
create table refresh_token
|
||||
(
|
||||
uid integer not null,
|
||||
device text not null,
|
||||
token text not null,
|
||||
created_at timestamp not null default current_timestamp,
|
||||
updated_at timestamp not null default current_timestamp,
|
||||
primary key (uid, device),
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create table device
|
||||
(
|
||||
uid integer not null,
|
||||
device text not null,
|
||||
device_token text,
|
||||
created_at timestamp not null default current_timestamp,
|
||||
updated_at timestamp not null default current_timestamp,
|
||||
primary key (uid, device),
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create table `group`
|
||||
(
|
||||
gid integer primary key autoincrement not null,
|
||||
name text not null,
|
||||
owner integer,
|
||||
is_public bool not null default false,
|
||||
description text not null default '',
|
||||
created_at timestamp not null default current_timestamp,
|
||||
updated_at timestamp not null default current_timestamp
|
||||
);
|
||||
|
||||
create table group_user
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
gid integer not null,
|
||||
uid integer not null,
|
||||
foreign key (gid) references `group` (gid) on delete cascade,
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index group_user_gid_uid on group_user (gid, uid);
|
||||
create index group_user_gid on group_user (gid);
|
||||
@@ -0,0 +1,13 @@
|
||||
create table user_log
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
uid integer not null,
|
||||
action integer not null,
|
||||
email text,
|
||||
name text,
|
||||
gender integer,
|
||||
language text,
|
||||
is_admin bool,
|
||||
avatar_updated_at timestamp,
|
||||
created_at timestamp not null default current_timestamp
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
create table operation_log
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
log text not null,
|
||||
created_at timestamp not null default current_timestamp
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
create table mute
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
uid integer not null,
|
||||
mute_uid integer,
|
||||
mute_gid integer,
|
||||
expired_at timestamp,
|
||||
foreign key (uid) references user (uid) on delete cascade,
|
||||
foreign key (mute_uid) references `user` (uid) on delete cascade,
|
||||
foreign key (mute_gid) references `group` (gid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index mute_uid_uid on mute (uid, mute_uid);
|
||||
create unique index mute_uid_gid on mute (uid, mute_gid);
|
||||
create index mute_uid on mute (uid);
|
||||
create index mute_expired_at on mute (expired_at);
|
||||
@@ -0,0 +1,14 @@
|
||||
create table metamask_auth
|
||||
(
|
||||
public_address text primary key not null,
|
||||
uid integer not null,
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index metamask_auth_uid on metamask_auth (uid);
|
||||
|
||||
create table metamask_nonce
|
||||
(
|
||||
public_address text primary key not null,
|
||||
nonce text not null
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
create table read_index
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
uid integer not null,
|
||||
target_uid integer,
|
||||
target_gid integer,
|
||||
mid integer not null,
|
||||
foreign key (uid) references user (uid) on delete cascade,
|
||||
foreign key (target_uid) references `user` (uid) on delete cascade,
|
||||
foreign key (target_gid) references `group` (gid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index read_index_uid_uid on read_index (uid, target_uid);
|
||||
create unique index read_index_uid_gid on read_index (uid, target_gid);
|
||||
create index read_index_uid on read_index (uid);
|
||||
@@ -0,0 +1,15 @@
|
||||
create table burn_after_reading
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
uid integer not null,
|
||||
target_uid integer,
|
||||
target_gid integer,
|
||||
expires_in integer not null,
|
||||
foreign key (uid) references user (uid) on delete cascade,
|
||||
foreign key (target_uid) references `user` (uid) on delete cascade,
|
||||
foreign key (target_gid) references `group` (gid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index burn_after_reading_uid_uid on burn_after_reading (uid, target_uid);
|
||||
create unique index burn_after_reading_uid_gid on burn_after_reading (uid, target_gid);
|
||||
create index burn_after_reading_uid on burn_after_reading (uid);
|
||||
@@ -0,0 +1,6 @@
|
||||
create table config
|
||||
(
|
||||
name text primary key,
|
||||
enabled bool not null default false,
|
||||
value text not null
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
alter table user add column status integer not null default 0;
|
||||
@@ -0,0 +1 @@
|
||||
alter table `group` add column avatar_updated_at timestamp not null default '1970-01-01 00:00:00';
|
||||
@@ -0,0 +1,6 @@
|
||||
create table third_party_users
|
||||
(
|
||||
userid text primary key not null,
|
||||
uid integer not null,
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
create table favorite_archive
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
uid integer not null,
|
||||
archive_id text not null,
|
||||
created_at timestamp not null,
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index favorite_archive_uid_archive_id on favorite_archive (uid, archive_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
create table pinned_message
|
||||
(
|
||||
id integer primary key autoincrement not null,
|
||||
gid integer not null,
|
||||
mid integer not null,
|
||||
created_by integer not null,
|
||||
created_at timestamp not null,
|
||||
foreign key (created_by) references `user` (uid) on delete cascade,
|
||||
foreign key (gid) references `group` (gid) on delete cascade
|
||||
);
|
||||
|
||||
create index pinned_message_gid on pinned_message (gid);
|
||||
create unique index pinned_message_gid_mid on pinned_message (gid, mid);
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
create table github_auth
|
||||
(
|
||||
username text primary key not null,
|
||||
uid integer not null,
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create unique index github_auth_uid on github_auth (uid);
|
||||
@@ -0,0 +1,16 @@
|
||||
create table user_tmp
|
||||
(
|
||||
uid integer primary key autoincrement not null,
|
||||
name text collate nocase not null,
|
||||
password text,
|
||||
email text collate nocase,
|
||||
gender integer not null,
|
||||
language text not null,
|
||||
is_admin boolean not null default false,
|
||||
create_by text not null,
|
||||
avatar_updated_at timestamp not null default '1970-01-01 00:00:00',
|
||||
created_at timestamp not null default current_timestamp,
|
||||
updated_at timestamp not null default current_timestamp
|
||||
);
|
||||
|
||||
create unique index user_tmp_email on user (email);
|
||||
@@ -0,0 +1 @@
|
||||
alter table user add column is_guest boolean not null default false;
|
||||
@@ -0,0 +1,2 @@
|
||||
drop index pinned_message_gid_mid;
|
||||
create index pinned_message_gid_mid on pinned_message (gid, mid);
|
||||
@@ -0,0 +1,4 @@
|
||||
alter table
|
||||
user
|
||||
add
|
||||
column webhook_url string;
|
||||
@@ -0,0 +1,9 @@
|
||||
alter table
|
||||
user
|
||||
add
|
||||
column is_bot boolean not null default false;
|
||||
|
||||
alter table
|
||||
user_log
|
||||
add
|
||||
column is_bot boolean;
|
||||
@@ -0,0 +1,13 @@
|
||||
create table bot_key (
|
||||
id integer primary key autoincrement not null,
|
||||
uid integer not null,
|
||||
name string not null,
|
||||
key string not null,
|
||||
created_at timestamp not null default current_timestamp,
|
||||
last_used timestamp,
|
||||
foreign key (uid) references user (uid) on delete cascade
|
||||
);
|
||||
|
||||
create index bot_key_uid on bot_key (uid);
|
||||
|
||||
create unique index bot_key_uid_name on bot_key (uid, name);
|
||||
@@ -0,0 +1,155 @@
|
||||
use poem::{
|
||||
error::{InternalServerError, ServiceUnavailable},
|
||||
http::StatusCode,
|
||||
web::Data,
|
||||
Error, Result,
|
||||
};
|
||||
use poem_openapi::{param::Query, payload::Json, Object, OpenApi};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token, DateTime},
|
||||
config::Config,
|
||||
state::{DynamicConfig, DynamicConfigEntry},
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiAdminAgora;
|
||||
|
||||
#[derive(Debug, Object, Serialize, Deserialize, Default)]
|
||||
pub struct AgoraConfig {
|
||||
#[oai(default = "default_agora_url")]
|
||||
pub url: String,
|
||||
pub project_id: String,
|
||||
pub app_id: String,
|
||||
pub app_certificate: String,
|
||||
pub rtm_key: String,
|
||||
pub rtm_secret: String,
|
||||
}
|
||||
|
||||
fn default_agora_url() -> String {
|
||||
"https://api.agora.io".to_string()
|
||||
}
|
||||
|
||||
impl DynamicConfig for AgoraConfig {
|
||||
type Instance = Self;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"agora"
|
||||
}
|
||||
|
||||
fn create_instance(self, _config: &Config) -> Self::Instance {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Agora config
|
||||
#[derive(Debug, Object)]
|
||||
pub struct AgoraConfigObject {
|
||||
enabled: bool,
|
||||
#[oai(flatten)]
|
||||
config: AgoraConfig,
|
||||
}
|
||||
|
||||
/// Agora usage response
|
||||
#[derive(Deserialize, Object)]
|
||||
struct AgoraUsagesResponse {
|
||||
usages: Vec<AgoraUsageItem>,
|
||||
}
|
||||
|
||||
/// Agora usage item
|
||||
#[derive(Deserialize, Object)]
|
||||
struct AgoraUsageItem {
|
||||
date: DateTime,
|
||||
usage: AgoraUsage,
|
||||
}
|
||||
|
||||
/// Agora usage
|
||||
#[derive(Deserialize, Object)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgoraUsage {
|
||||
duration_audio_all: i64,
|
||||
duration_video_hd: i64,
|
||||
duration_video_hdp: i64,
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/agora", tag = "ApiTags::AdminAgora")]
|
||||
impl ApiAdminAgora {
|
||||
/// Set Agora config
|
||||
#[oai(path = "/config", method = "post")]
|
||||
async fn set_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
config: Json<AgoraConfigObject>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: config.0.enabled,
|
||||
config: config.0.config,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get Agora config
|
||||
#[oai(path = "/config", method = "get")]
|
||||
async fn get_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
_token: Token,
|
||||
) -> Result<Json<AgoraConfigObject>> {
|
||||
// if !token.is_admin {
|
||||
// return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
// }
|
||||
let entry = state.load_dynamic_config::<AgoraConfig>().await?;
|
||||
Ok(Json(AgoraConfigObject {
|
||||
enabled: entry.enabled,
|
||||
config: entry.config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get Agora usage
|
||||
#[oai(path = "/usages", method = "get")]
|
||||
async fn usage(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
/// Start date(YYYY-MM-DD)
|
||||
from_date: Query<String>,
|
||||
/// End date(YYYY-MM-DD)
|
||||
to_date: Query<String>,
|
||||
) -> Result<Json<AgoraUsagesResponse>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let agora = state
|
||||
.get_dynamic_config_instance::<AgoraConfig>()
|
||||
.await
|
||||
.ok_or_else(|| Error::from_status(StatusCode::SERVICE_UNAVAILABLE))?;
|
||||
let resp = reqwest::Client::new()
|
||||
.get(format!("{}/dev/v3/usage", agora.url))
|
||||
.query(&[
|
||||
("project_id", agora.project_id.as_str()),
|
||||
("from_date", from_date.as_str()),
|
||||
("to_date", to_date.as_str()),
|
||||
("business", "default"),
|
||||
])
|
||||
.header("accept", "application/json")
|
||||
.basic_auth(&agora.rtm_key, Some(&agora.rtm_secret))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ServiceUnavailable)?
|
||||
.error_for_status()
|
||||
.map_err(ServiceUnavailable)?
|
||||
.json::<AgoraUsagesResponse>()
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use poem::{http::StatusCode, web::Data, Error, Result};
|
||||
use poem_openapi::{payload::Json, Object, OpenApi};
|
||||
use rc_fcm::{ApplicationCredentials, FcmClient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token},
|
||||
config::Config,
|
||||
state::{DynamicConfig, DynamicConfigEntry},
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiAdminFirebase;
|
||||
|
||||
/// Firebase config
|
||||
#[derive(Debug, Object, Serialize, Deserialize, Default)]
|
||||
pub struct FcmConfig {
|
||||
#[serde(default = "default_use_official")]
|
||||
pub use_official: bool,
|
||||
#[oai(default = "default_token_url")]
|
||||
pub token_url: String,
|
||||
pub project_id: String,
|
||||
pub private_key: String,
|
||||
pub client_email: String,
|
||||
}
|
||||
|
||||
fn default_use_official() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_token_url() -> String {
|
||||
"https://oauth2.googleapis.com/token".to_string()
|
||||
}
|
||||
|
||||
impl DynamicConfig for FcmConfig {
|
||||
type Instance = FcmClient;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"fcm"
|
||||
}
|
||||
|
||||
fn create_instance(self, config: &Config) -> Self::Instance {
|
||||
if self.use_official {
|
||||
FcmClient::new(ApplicationCredentials {
|
||||
project_id: config.offical_fcm_config.project_id.clone(),
|
||||
private_key: config.offical_fcm_config.private_key.clone(),
|
||||
client_email: config.offical_fcm_config.client_email.clone(),
|
||||
token_uri: config.offical_fcm_config.token_uri.clone(),
|
||||
})
|
||||
} else {
|
||||
FcmClient::new(ApplicationCredentials {
|
||||
project_id: self.project_id,
|
||||
private_key: self.private_key,
|
||||
client_email: self.client_email,
|
||||
token_uri: self.token_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Firebase config
|
||||
#[derive(Debug, Object)]
|
||||
pub struct FcmConfigObject {
|
||||
enabled: bool,
|
||||
#[oai(flatten)]
|
||||
config: FcmConfig,
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/fcm", tag = "ApiTags::AdminFirebase")]
|
||||
impl ApiAdminFirebase {
|
||||
/// Set Firebase config
|
||||
#[oai(path = "/config", method = "post")]
|
||||
async fn set_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
config: Json<FcmConfigObject>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: config.0.enabled,
|
||||
config: config.0.config,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get Firebase config
|
||||
#[oai(path = "/config", method = "get")]
|
||||
async fn get_config(&self, state: Data<&State>, token: Token) -> Result<Json<FcmConfigObject>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
let entry = state.load_dynamic_config::<FcmConfig>().await?;
|
||||
Ok(Json(FcmConfigObject {
|
||||
enabled: entry.enabled,
|
||||
config: entry.config,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use poem::{http::StatusCode, web::Data, Error, Result};
|
||||
use poem_openapi::{payload::Json, Object, OpenApi};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token},
|
||||
config::Config,
|
||||
state::{DynamicConfig, DynamicConfigEntry},
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiAdminGithubAuth;
|
||||
|
||||
/// Github authentication config
|
||||
#[derive(Debug, Object, Serialize, Deserialize, Default)]
|
||||
pub struct GithubAuthConfig {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
impl DynamicConfig for GithubAuthConfig {
|
||||
type Instance = GithubAuthConfig;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"github-auth"
|
||||
}
|
||||
|
||||
fn create_instance(self, _config: &Config) -> Self::Instance {
|
||||
GithubAuthConfig {
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/github_auth", tag = "ApiTags::AdminGithubAuth")]
|
||||
impl ApiAdminGithubAuth {
|
||||
/// Set Github auth config
|
||||
#[oai(path = "/config", method = "post")]
|
||||
async fn set_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
config: Json<GithubAuthConfig>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: true,
|
||||
config: config.0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get Github auth config
|
||||
#[oai(path = "/config", method = "get")]
|
||||
async fn get_config(&self, state: Data<&State>) -> Result<Json<GithubAuthConfig>> {
|
||||
let entry = state.load_dynamic_config::<GithubAuthConfig>().await?;
|
||||
Ok(Json(entry.config))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_harness::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_get_github_oauth() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
|
||||
let resp = server
|
||||
.post("/api/admin/github_auth/config")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({
|
||||
"client_id": "test",
|
||||
"client_secret": "test",
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
let resp = server
|
||||
.get("/api/admin/github_auth/config")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
// let body = resp.0.take_body().into_string().await.unwrap();
|
||||
// dbg!(body);
|
||||
|
||||
let json = resp.json().await;
|
||||
json.value().object().get("client_id").assert_string("test");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use poem::{http::StatusCode, web::Data, Error, Result};
|
||||
use poem_openapi::{payload::Json, Object, OpenApi};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token},
|
||||
config::Config,
|
||||
state::{DynamicConfig, DynamicConfigEntry},
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiAdminGoogleAuth;
|
||||
|
||||
/// Google authentication config
|
||||
#[derive(Debug, Object, Serialize, Deserialize, Default)]
|
||||
pub struct GoogleAuthConfig {
|
||||
pub client_id: String,
|
||||
}
|
||||
|
||||
impl DynamicConfig for GoogleAuthConfig {
|
||||
type Instance = GoogleAuthConfig;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"google-auth"
|
||||
}
|
||||
|
||||
fn create_instance(self, _config: &Config) -> Self::Instance {
|
||||
GoogleAuthConfig {
|
||||
client_id: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/google_auth", tag = "ApiTags::AdminGoogleAuth")]
|
||||
impl ApiAdminGoogleAuth {
|
||||
/// Set Google auth config
|
||||
#[oai(path = "/config", method = "post")]
|
||||
async fn set_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
config: Json<GoogleAuthConfig>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: true,
|
||||
config: config.0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get Google auth config
|
||||
#[oai(path = "/config", method = "get")]
|
||||
async fn get_config(&self, state: Data<&State>) -> Result<Json<GoogleAuthConfig>> {
|
||||
let entry = state.load_dynamic_config::<GoogleAuthConfig>().await?;
|
||||
Ok(Json(entry.config))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use poem::{http::StatusCode, web::Data, Error, Result};
|
||||
use poem_openapi::{payload::Json, Enum, Object, OpenApi};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token},
|
||||
config::Config,
|
||||
state::{DynamicConfig, DynamicConfigEntry},
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiAdminLogin;
|
||||
|
||||
#[derive(Debug, Object, Serialize, Deserialize)]
|
||||
pub struct OIDCConfig {
|
||||
pub enable: bool,
|
||||
pub favicon: String,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Enum, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)]
|
||||
pub enum WhoCanSignUp {
|
||||
EveryOne,
|
||||
InvitationOnly,
|
||||
}
|
||||
|
||||
/// Login config
|
||||
#[derive(Debug, Object, Serialize, Deserialize)]
|
||||
pub struct LoginConfig {
|
||||
/// Who can sign up
|
||||
#[serde(default = "default_who_can_sign_up")]
|
||||
pub who_can_sign_up: WhoCanSignUp,
|
||||
/// Login as guest
|
||||
#[serde(default)]
|
||||
pub guest: bool,
|
||||
/// Login with password
|
||||
#[serde(default)]
|
||||
pub password: bool,
|
||||
/// Login with magic link
|
||||
#[serde(default)]
|
||||
pub magic_link: bool,
|
||||
/// Login with Google
|
||||
#[serde(default)]
|
||||
pub google: bool,
|
||||
/// Login with Github
|
||||
#[serde(default)]
|
||||
pub github: bool,
|
||||
/// Login with OpenID Connect
|
||||
#[serde(default)]
|
||||
pub oidc: Vec<OIDCConfig>,
|
||||
/// Login with Metamask
|
||||
#[serde(default)]
|
||||
pub metamask: bool,
|
||||
/// Login with third party
|
||||
#[serde(default)]
|
||||
pub third_party: bool,
|
||||
}
|
||||
|
||||
const fn default_who_can_sign_up() -> WhoCanSignUp {
|
||||
WhoCanSignUp::EveryOne
|
||||
}
|
||||
|
||||
impl Default for LoginConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
who_can_sign_up: WhoCanSignUp::EveryOne,
|
||||
guest: false,
|
||||
password: true,
|
||||
magic_link: true,
|
||||
google: false,
|
||||
github: false,
|
||||
oidc: vec![],
|
||||
metamask: false,
|
||||
third_party: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicConfig for LoginConfig {
|
||||
type Instance = Self;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"login"
|
||||
}
|
||||
|
||||
fn create_instance(self, _config: &Config) -> Self::Instance {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/login", tag = "ApiTags::AdminLogin")]
|
||||
impl ApiAdminLogin {
|
||||
/// Set login config
|
||||
#[oai(path = "/config", method = "post")]
|
||||
async fn set_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
config: Json<LoginConfig>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: true,
|
||||
config: config.0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get login config
|
||||
#[oai(path = "/config", method = "get")]
|
||||
async fn get_config(&self, state: Data<&State>) -> Result<Json<LoginConfig>> {
|
||||
let entry = state.load_dynamic_config::<LoginConfig>().await?;
|
||||
Ok(Json(entry.config))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use poem::{http::StatusCode, web::Data, Error, Result};
|
||||
use poem_openapi::{payload::Json, Object, OpenApi};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token},
|
||||
config::Config,
|
||||
state::{DynamicConfig, DynamicConfigEntry},
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiAdminSmtp;
|
||||
|
||||
#[derive(Debug, Object, Serialize, Deserialize, Default)]
|
||||
pub struct SmtpConfig {
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub from: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl DynamicConfig for SmtpConfig {
|
||||
type Instance = Self;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"smtp"
|
||||
}
|
||||
|
||||
fn create_instance(self, _config: &Config) -> Self::Instance {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// SMTP config
|
||||
#[derive(Debug, Object)]
|
||||
pub struct SmtpConfigObject {
|
||||
enabled: bool,
|
||||
#[oai(flatten)]
|
||||
config: SmtpConfig,
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/smtp", tag = "ApiTags::AdminSmtp")]
|
||||
impl ApiAdminSmtp {
|
||||
/// Set SMTP config
|
||||
#[oai(path = "/config", method = "post")]
|
||||
async fn set_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
config: Json<SmtpConfigObject>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: config.0.enabled,
|
||||
config: config.0.config,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get SMTP config
|
||||
#[oai(path = "/config", method = "get")]
|
||||
async fn get_config(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
) -> Result<Json<SmtpConfigObject>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
let entry = state.load_dynamic_config::<SmtpConfig>().await?;
|
||||
Ok(Json(SmtpConfigObject {
|
||||
enabled: entry.enabled,
|
||||
config: entry.config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get SMTP config is enabled
|
||||
#[oai(path = "/enabled", method = "get")]
|
||||
async fn is_enabled(&self, state: Data<&State>) -> Result<Json<bool>> {
|
||||
let entry = state.load_dynamic_config::<SmtpConfig>().await?;
|
||||
Ok(Json(entry.enabled))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
use image::ImageFormat;
|
||||
use poem::{error::InternalServerError, http::StatusCode, web::Data, Error, Result};
|
||||
use poem_openapi::{
|
||||
payload::{Binary, Json, PlainText},
|
||||
types::Email,
|
||||
ApiRequest, Object, OpenApi,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token, SmtpConfig, UserInfo},
|
||||
config::Config,
|
||||
create_user::{CreateUser, CreateUserBy},
|
||||
server::create_random_str,
|
||||
state::{send_mail, DynamicConfig, DynamicConfigEntry},
|
||||
State,
|
||||
};
|
||||
|
||||
/// Server metrics
|
||||
#[derive(Debug, Object)]
|
||||
pub struct Metrics {
|
||||
user_count: usize,
|
||||
group_count: usize,
|
||||
online_user_count: usize,
|
||||
version: String,
|
||||
}
|
||||
|
||||
/// Frontend url
|
||||
#[derive(Debug, Object, Serialize, Deserialize, Default)]
|
||||
pub struct FrontendUrlConfig {
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
impl DynamicConfig for FrontendUrlConfig {
|
||||
type Instance = Self;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"frontend-url"
|
||||
}
|
||||
|
||||
fn create_instance(self, _config: &Config) -> Self::Instance {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Organization info
|
||||
#[derive(Debug, Object, Serialize, Deserialize)]
|
||||
pub struct OrganizationConfig {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl DynamicConfig for OrganizationConfig {
|
||||
type Instance = Self;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"organization"
|
||||
}
|
||||
|
||||
fn create_instance(self, _config: &Config) -> Self::Instance {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrganizationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "unknown".to_string(),
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ApiRequest)]
|
||||
enum UploadLogoRequest {
|
||||
#[oai(content_type = "image/png")]
|
||||
Image(Binary<Vec<u8>>),
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct SendMailRequest {
|
||||
to: String,
|
||||
subject: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct CreateAdminRequest {
|
||||
email: Email,
|
||||
name: String,
|
||||
password: String,
|
||||
gender: i32,
|
||||
}
|
||||
|
||||
pub struct ApiAdminSystem;
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/system", tag = "ApiTags::AdminSystem")]
|
||||
impl ApiAdminSystem {
|
||||
/// Get the server version
|
||||
#[oai(path = "/version", method = "get")]
|
||||
async fn version(&self) -> PlainText<&'static str> {
|
||||
PlainText(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Create administrator user
|
||||
#[oai(path = "/create_admin", method = "post")]
|
||||
async fn create_admin_user(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
mut req: Json<CreateAdminRequest>,
|
||||
) -> Result<Json<UserInfo>> {
|
||||
if !state.cache.read().await.users.is_empty() {
|
||||
return Err(poem::Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
req.email.0 = req.email.0.to_lowercase();
|
||||
let (uid, user) = match state
|
||||
.create_user(
|
||||
CreateUser::new(
|
||||
&req.name,
|
||||
CreateUserBy::Password {
|
||||
email: &req.email,
|
||||
password: &req.password,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.gender(req.gender),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(_) => return Err(poem::Error::from_status(StatusCode::FORBIDDEN)),
|
||||
};
|
||||
|
||||
// update user.is_admin
|
||||
Ok(Json(user.api_user_info(uid)))
|
||||
}
|
||||
|
||||
/// Returns `true` means that the server has been initialized
|
||||
#[oai(path = "/initialized", method = "get")]
|
||||
async fn initialized(&self, state: Data<&State>) -> Json<bool> {
|
||||
let cache = state.cache.read().await;
|
||||
Json(!cache.users.is_empty())
|
||||
}
|
||||
|
||||
/// Get the system metrics
|
||||
#[oai(path = "/metrics", method = "get")]
|
||||
async fn get_metrics(&self, state: Data<&State>, token: Token) -> Result<Json<Metrics>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let cache = state.cache.read().await;
|
||||
Ok(Json(Metrics {
|
||||
user_count: cache.users.iter().filter(|user| !user.1.is_guest).count(),
|
||||
group_count: cache.groups.len(),
|
||||
online_user_count: cache
|
||||
.users
|
||||
.values()
|
||||
.filter(|user| !user.is_guest && user.is_online())
|
||||
.count(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get the organization info
|
||||
#[oai(path = "/organization", method = "get")]
|
||||
async fn get_organization(&self, state: Data<&State>) -> Result<Json<OrganizationConfig>> {
|
||||
let entry = state.load_dynamic_config::<OrganizationConfig>().await?;
|
||||
Ok(Json(entry.config))
|
||||
}
|
||||
|
||||
/// Set the organization info
|
||||
#[oai(path = "/organization", method = "post")]
|
||||
async fn set_organization(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
req: Json<OrganizationConfig>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: true,
|
||||
config: req.0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload the organization logo
|
||||
#[oai(path = "/organization/logo", method = "post")]
|
||||
async fn upload_organization_logo(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
logo: UploadLogoRequest,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let UploadLogoRequest::Image(data) = logo;
|
||||
let logo = image::load_from_memory(&data).map_err(InternalServerError)?;
|
||||
let logo = logo.thumbnail(240, 240);
|
||||
let path = state.config.system.data_dir.join("organization.png");
|
||||
logo.save_with_format(path, ImageFormat::Png)
|
||||
.map_err(InternalServerError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send email(only for test)
|
||||
#[oai(path = "/send_mail", method = "post")]
|
||||
async fn send_mail(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
req: Json<SendMailRequest>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let smtp_config = state
|
||||
.get_dynamic_config_instance::<SmtpConfig>()
|
||||
.await
|
||||
.ok_or_else(|| Error::from_status(StatusCode::SERVICE_UNAVAILABLE))?;
|
||||
Ok(send_mail(&smtp_config, &req.to, &req.subject, &req.content).await?)
|
||||
}
|
||||
|
||||
/// Get the secret for third-party authentication
|
||||
#[oai(path = "/third_party_secret", method = "get")]
|
||||
async fn third_party_secret(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
) -> Result<PlainText<String>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
let key_config = state.key_config.read().await;
|
||||
Ok(PlainText(key_config.third_party_secret.clone()))
|
||||
}
|
||||
|
||||
/// Update third-party secret
|
||||
#[oai(path = "/third_party_secret", method = "post")]
|
||||
async fn update_third_party_secret(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
) -> Result<PlainText<String>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let mut key_config = state.key_config.write().await;
|
||||
let new_third_party_secret = create_random_str(32);
|
||||
key_config.third_party_secret = new_third_party_secret.clone();
|
||||
|
||||
let key_config_path = state.config.system.data_dir.join("key.json");
|
||||
std::fs::write(
|
||||
key_config_path,
|
||||
serde_json::to_vec(&*key_config).map_err(InternalServerError)?,
|
||||
)
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
Ok(PlainText(new_third_party_secret))
|
||||
}
|
||||
|
||||
/// Get the frontend url
|
||||
#[oai(path = "/frontend_url", method = "get")]
|
||||
async fn get_frontend_url(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
) -> Result<PlainText<String>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
Ok(PlainText(
|
||||
state
|
||||
.get_dynamic_config_instance::<FrontendUrlConfig>()
|
||||
.await
|
||||
.and_then(|config| config.url.clone())
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Update the frontend url
|
||||
#[oai(path = "/update_frontend_url", method = "post")]
|
||||
async fn update_frontend_url(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
frontend_url: PlainText<String>,
|
||||
) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let frontend_url = frontend_url.0.trim_end_matches('/');
|
||||
let re = regex::Regex::new(r#"^https?://[\w\-\.]+(:\d+)?$"#).unwrap();
|
||||
if !re.is_match(frontend_url) {
|
||||
return Err(Error::from_string(
|
||||
"Bad url format!",
|
||||
StatusCode::BAD_REQUEST,
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.set_dynamic_config(DynamicConfigEntry {
|
||||
enabled: true,
|
||||
config: FrontendUrlConfig {
|
||||
url: Some(frontend_url.to_string()),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frontend_url() {
|
||||
let re = regex::Regex::new(r#"^https?://[\w\-\.]+(:\d+)?$"#).unwrap();
|
||||
assert!(re.is_match("http://1.2.3.4:4000"));
|
||||
assert!(re.is_match("http://domain.com"));
|
||||
assert!(re.is_match("http://domain.com:3000"));
|
||||
assert!(re.is_match("https://domain.com:3000"));
|
||||
assert!(re.is_match("http://127.0.0.1"));
|
||||
assert!(re.is_match("http://127.0.0.1:3000"));
|
||||
assert!(re.is_match("https://127.0.0.1:3000"));
|
||||
assert!(!re.is_match("ftp://127.0.0.1:3000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_config() {
|
||||
let a = r#"frontend_url = "http://a.com/""#;
|
||||
let re = regex::Regex::new(r#"frontend_url\s*=\s*".*?""#).unwrap();
|
||||
let b = re.replace(a, format!(r#"frontend_url = "{}""#, "http://b.com/"));
|
||||
assert_eq!(b, r#"frontend_url = "http://b.com/""#);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_harness::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_organization() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
|
||||
let resp = server.get("/api/admin/system/organization").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
resp.assert_json(&json!({
|
||||
"name": "unknown",
|
||||
"description": null,
|
||||
}))
|
||||
.await;
|
||||
|
||||
server
|
||||
.post("/api/admin/system/organization")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({
|
||||
"name": "abc",
|
||||
"description": "def"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.assert_status_is_ok();
|
||||
|
||||
let resp = server.get("/api/admin/system/organization").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
resp.assert_json(&json!({
|
||||
"name": "abc",
|
||||
"description": "def",
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_frontend_url() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
|
||||
let resp = server
|
||||
.post("/api/admin/system/update_frontend_url")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.content_type("text/plain")
|
||||
.body("http://1.2.3.4:4000")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use poem::{
|
||||
error::{InternalServerError, ReadBodyError},
|
||||
http::StatusCode,
|
||||
web::Data,
|
||||
Error, Result,
|
||||
};
|
||||
use poem_openapi::{param::Path, payload::Json, types::Email, ApiResponse, Object, OpenApi};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
tags::ApiTags,
|
||||
token::Token,
|
||||
user::{UploadAvatarApiResponse, UploadAvatarRequest},
|
||||
CreateUserConflictReason, CreateUserResponse, DateTime, KickReason, LangId, UpdateAction,
|
||||
UpdateUserResponse, UserConflict, UserUpdateLog,
|
||||
},
|
||||
api_key::create_api_key,
|
||||
create_user::{CreateUser, CreateUserBy, CreateUserError},
|
||||
state::{BotKey, BroadcastEvent, UserEvent, UserStatus},
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiAdminUser;
|
||||
|
||||
/// User device
|
||||
#[derive(Debug, Object)]
|
||||
pub struct UserDevice {
|
||||
pub device: String,
|
||||
pub device_token: Option<String>,
|
||||
pub is_online: bool,
|
||||
}
|
||||
|
||||
/// Create user request
|
||||
#[derive(Debug, Object)]
|
||||
pub struct CreateUserRequest {
|
||||
pub email: Email,
|
||||
pub password: String,
|
||||
#[oai(validator(max_length = 32))]
|
||||
pub name: String,
|
||||
pub gender: i32,
|
||||
pub is_admin: bool,
|
||||
#[oai(default)]
|
||||
pub language: LangId,
|
||||
pub webhook_url: Option<String>,
|
||||
#[oai(default)]
|
||||
pub is_bot: bool,
|
||||
}
|
||||
|
||||
/// User info for admin
|
||||
#[derive(Debug, Object)]
|
||||
pub struct User {
|
||||
/// User id
|
||||
pub uid: i64,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
pub name: String,
|
||||
pub gender: i32,
|
||||
pub is_admin: bool,
|
||||
pub language: LangId,
|
||||
pub create_by: String,
|
||||
pub in_online: bool,
|
||||
pub online_devices: Vec<UserDevice>,
|
||||
pub created_at: DateTime,
|
||||
pub updated_at: DateTime,
|
||||
pub avatar_updated_at: DateTime,
|
||||
pub status: UserStatus,
|
||||
pub webhook_url: Option<String>,
|
||||
pub is_bot: bool,
|
||||
}
|
||||
|
||||
/// Update user request
|
||||
#[derive(Debug, Object)]
|
||||
pub struct UpdateUserRequest {
|
||||
email: Option<String>,
|
||||
password: Option<String>,
|
||||
#[oai(validator(max_length = 32))]
|
||||
name: Option<String>,
|
||||
gender: Option<i32>,
|
||||
is_admin: Option<bool>,
|
||||
language: Option<LangId>,
|
||||
status: Option<UserStatus>,
|
||||
webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateUserRequest {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.email.is_none()
|
||||
&& self.password.is_none()
|
||||
&& self.name.is_none()
|
||||
&& self.gender.is_none()
|
||||
&& self.is_admin.is_none()
|
||||
&& self.language.is_none()
|
||||
&& self.status.is_none()
|
||||
&& self.webhook_url.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create bot api key request
|
||||
#[derive(Debug, Object)]
|
||||
pub struct CreateBotApiKeyRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Create bot api key response
|
||||
#[derive(Debug, ApiResponse)]
|
||||
pub enum CreateBotApiKeyResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<String>),
|
||||
/// Key name conflict
|
||||
#[oai(status = 409)]
|
||||
ConflictName,
|
||||
}
|
||||
|
||||
/// Delete bot api key request
|
||||
#[derive(Debug, Object)]
|
||||
pub struct DeleteBotApiKeyRequest {
|
||||
uid: i64,
|
||||
}
|
||||
|
||||
/// Delete bot api key response
|
||||
#[derive(Debug, ApiResponse)]
|
||||
pub enum DeleteBotApiKeyResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok,
|
||||
/// Key not found
|
||||
#[oai(status = 404)]
|
||||
KeyNotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object)]
|
||||
#[oai(rename = "BotKey")]
|
||||
pub struct BotKeyInfo {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
pub created_at: DateTime,
|
||||
pub last_used: Option<DateTime>,
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/admin/user", tag = "ApiTags::AdminUser")]
|
||||
impl ApiAdminUser {
|
||||
/// Create a user
|
||||
#[oai(path = "/", method = "post")]
|
||||
async fn create(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
mut req: Json<CreateUserRequest>,
|
||||
token: Token,
|
||||
) -> Result<CreateUserResponse> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
req.email.0 = req.email.0.to_lowercase();
|
||||
let mut create_user = CreateUser::new(
|
||||
&req.name,
|
||||
CreateUserBy::Password {
|
||||
email: &req.email,
|
||||
password: &req.password,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.gender(req.gender)
|
||||
.set_admin(req.is_admin)
|
||||
.language(&req.language)
|
||||
.set_bot(req.is_bot);
|
||||
|
||||
if let Some(webhook_url) = &req.0.webhook_url {
|
||||
// check the webhook url
|
||||
if !matches!(
|
||||
reqwest::get(webhook_url).await.map(|resp| resp.status()),
|
||||
Ok(StatusCode::OK)
|
||||
) {
|
||||
return Ok(CreateUserResponse::InvalidWebhookUrl);
|
||||
}
|
||||
|
||||
create_user = create_user.webhook_url(webhook_url);
|
||||
}
|
||||
let res = state.create_user(create_user).await;
|
||||
|
||||
match res {
|
||||
Ok((uid, user)) => Ok(CreateUserResponse::Ok(Json(user.api_user(uid)))),
|
||||
Err(CreateUserError::NameConflict) => {
|
||||
Ok(CreateUserResponse::Conflict(Json(UserConflict {
|
||||
reason: CreateUserConflictReason::NameConflict,
|
||||
})))
|
||||
}
|
||||
Err(CreateUserError::EmailConflict) => {
|
||||
Ok(CreateUserResponse::Conflict(Json(UserConflict {
|
||||
reason: CreateUserConflictReason::EmailConflict,
|
||||
})))
|
||||
}
|
||||
Err(CreateUserError::PoemError(err)) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the user by id
|
||||
#[oai(path = "/:uid", method = "get")]
|
||||
async fn get(&self, state: Data<&State>, token: Token, uid: Path<i64>) -> Result<Json<User>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let cache = state.cache.read().await;
|
||||
let user = cache
|
||||
.users
|
||||
.get(&uid.0)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?;
|
||||
Ok(Json(user.api_user(uid.0)))
|
||||
}
|
||||
|
||||
/// Get all users
|
||||
#[oai(path = "/", method = "get")]
|
||||
async fn get_all(&self, state: Data<&State>, token: Token) -> Result<Json<Vec<User>>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let cache = state.cache.read().await;
|
||||
let users = cache
|
||||
.users
|
||||
.iter()
|
||||
.filter(|(_, user)| !user.is_guest)
|
||||
.map(|(uid, user)| user.api_user(*uid))
|
||||
.collect();
|
||||
Ok(Json(users))
|
||||
}
|
||||
|
||||
/// Delete the user by id
|
||||
#[oai(path = "/:uid", method = "delete")]
|
||||
async fn delete(&self, state: Data<&State>, token: Token, uid: Path<i64>) -> Result<()> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
if uid.0 == token.uid || uid.0 == 1 {
|
||||
// cannot delete self and founder
|
||||
return Err(poem::Error::from(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
state.delete_user(uid.0).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update user by id
|
||||
#[oai(path = "/:uid", method = "put")]
|
||||
async fn update(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
uid: Path<i64>,
|
||||
req: Json<UpdateUserRequest>,
|
||||
) -> Result<UpdateUserResponse<User>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
if req.is_empty() {
|
||||
return Err(Error::from_status(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
let mut cache = state.cache.write().await;
|
||||
|
||||
if let Some(email) = &req.email {
|
||||
if !cache.check_email_conflict(email) {
|
||||
return Ok(UpdateUserResponse::Conflict(Json(UserConflict {
|
||||
reason: CreateUserConflictReason::EmailConflict,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = &req.name {
|
||||
if !cache.check_name_conflict(name) {
|
||||
return Ok(UpdateUserResponse::Conflict(Json(UserConflict {
|
||||
reason: CreateUserConflictReason::NameConflict,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// check webhook url
|
||||
if let Some(webhook_url) = &req.webhook_url {
|
||||
// check the webhook url
|
||||
if !matches!(
|
||||
reqwest::get(webhook_url).await.map(|resp| resp.status()),
|
||||
Ok(StatusCode::OK)
|
||||
) {
|
||||
return Ok(UpdateUserResponse::InvalidWebhookUrl);
|
||||
}
|
||||
}
|
||||
|
||||
let now = DateTime::now();
|
||||
let cached_user = cache
|
||||
.users
|
||||
.get_mut(&uid.0)
|
||||
.ok_or_else(|| Error::from(StatusCode::NOT_FOUND))?;
|
||||
|
||||
// begin transaction
|
||||
let mut tx = state.db_pool.begin().await.map_err(InternalServerError)?;
|
||||
|
||||
// update user table
|
||||
let sql = format!(
|
||||
"update user set {} where uid = ?",
|
||||
req.password
|
||||
.iter()
|
||||
.map(|_| "password = ?")
|
||||
.chain(req.email.iter().map(|_| "email = ?"))
|
||||
.chain(req.name.iter().map(|_| "name = ?"))
|
||||
.chain(req.gender.iter().map(|_| "gender = ?"))
|
||||
.chain(req.language.iter().map(|_| "language = ?"))
|
||||
.chain(req.is_admin.iter().map(|_| "is_admin = ?"))
|
||||
.chain(req.status.iter().map(|_| "status = ?"))
|
||||
.chain(req.webhook_url.iter().map(|_| "webhook_url = ?"))
|
||||
.chain(Some("updated_at = ?"))
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
if let Some(password) = &req.password {
|
||||
query = query.bind(password);
|
||||
}
|
||||
if let Some(email) = &req.email {
|
||||
query = query.bind(email);
|
||||
}
|
||||
if let Some(name) = &req.name {
|
||||
query = query.bind(name);
|
||||
}
|
||||
if let Some(gender) = &req.gender {
|
||||
query = query.bind(gender);
|
||||
}
|
||||
if let Some(language) = &req.language {
|
||||
query = query.bind(language);
|
||||
}
|
||||
if let Some(is_admin) = &req.is_admin {
|
||||
query = query.bind(is_admin);
|
||||
}
|
||||
if let Some(status) = &req.status {
|
||||
query = query.bind(i8::from(*status));
|
||||
}
|
||||
if let Some(webhook_url) = &req.webhook_url {
|
||||
query = query.bind(webhook_url);
|
||||
}
|
||||
|
||||
query
|
||||
.bind(now)
|
||||
.bind(uid.0)
|
||||
.execute(&mut tx)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
// insert into user_log table
|
||||
let sql = "insert into user_log (uid, action, email, name, gender, is_admin, language) values (?, ?, ?, ?, ?, ?, ?)";
|
||||
let log_id = sqlx::query(sql)
|
||||
.bind(uid.0)
|
||||
.bind(UpdateAction::Update)
|
||||
.bind(&req.email)
|
||||
.bind(&req.name)
|
||||
.bind(req.gender)
|
||||
.bind(req.is_admin)
|
||||
.bind(&req.language)
|
||||
.execute(&mut tx)
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
.last_insert_rowid();
|
||||
|
||||
// commit transaction
|
||||
tx.commit().await.map_err(InternalServerError)?;
|
||||
|
||||
// update cache
|
||||
if let Some(email) = &req.0.email {
|
||||
cached_user.email = Some(email.clone());
|
||||
}
|
||||
if let Some(name) = &req.0.name {
|
||||
cached_user.name = name.clone();
|
||||
}
|
||||
if let Some(password) = &req.0.password {
|
||||
cached_user.password = Some(password.clone());
|
||||
}
|
||||
if let Some(gender) = req.0.gender {
|
||||
cached_user.gender = gender;
|
||||
}
|
||||
if let Some(language) = &req.0.language {
|
||||
cached_user.language = language.clone();
|
||||
}
|
||||
if let Some(is_admin) = req.0.is_admin {
|
||||
cached_user.is_admin = is_admin;
|
||||
}
|
||||
if let Some(status) = &req.0.status {
|
||||
cached_user.status = *status;
|
||||
}
|
||||
if let Some(webhook_url) = req.0.webhook_url {
|
||||
cached_user.webhook_url = Some(webhook_url);
|
||||
}
|
||||
|
||||
if let Some(UserStatus::Frozen) = req.0.status {
|
||||
// close all subscriptions
|
||||
for device in cached_user.devices.values_mut() {
|
||||
if let Some(sender) = device.sender.take() {
|
||||
let _ = sender.send(UserEvent::Kick {
|
||||
reason: KickReason::Frozen,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast event
|
||||
let _ = state
|
||||
.event_sender
|
||||
.send(Arc::new(BroadcastEvent::UserLog(UserUpdateLog {
|
||||
log_id,
|
||||
action: UpdateAction::Update,
|
||||
uid: uid.0,
|
||||
email: req.0.email,
|
||||
name: req.0.name,
|
||||
gender: req.0.gender,
|
||||
language: req.0.language,
|
||||
is_admin: req.0.is_admin,
|
||||
is_bot: None,
|
||||
avatar_updated_at: None,
|
||||
})));
|
||||
|
||||
Ok(UpdateUserResponse::Ok(Json(cached_user.api_user(uid.0))))
|
||||
}
|
||||
|
||||
/// Upload avatar
|
||||
#[oai(path = "/:uid/avatar", method = "post")]
|
||||
async fn upload_avatar(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
uid: Path<i64>,
|
||||
req: UploadAvatarRequest,
|
||||
) -> Result<UploadAvatarApiResponse> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let mut cache = state.cache.write().await;
|
||||
let now = DateTime::now();
|
||||
let cached_user = cache
|
||||
.users
|
||||
.get_mut(&uid)
|
||||
.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
let UploadAvatarRequest::Image(data) = req;
|
||||
let data = match data
|
||||
.0
|
||||
.into_bytes_limit(state.config.system.upload_avatar_limit)
|
||||
.await
|
||||
{
|
||||
Ok(data) => data,
|
||||
Err(ReadBodyError::PayloadTooLarge) => {
|
||||
return Ok(UploadAvatarApiResponse::PayloadTooLarge);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
// write to file
|
||||
state.save_avatar(uid.0, &data)?;
|
||||
|
||||
// update sqlite
|
||||
let mut tx = state.db_pool.begin().await.map_err(InternalServerError)?;
|
||||
|
||||
sqlx::query("update user set avatar_updated_at = ? where uid = ?")
|
||||
.bind(now)
|
||||
.bind(uid.0)
|
||||
.execute(&mut tx)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
let log_id =
|
||||
sqlx::query("insert into user_log (uid, action, avatar_updated_at) values (?, ?, ?)")
|
||||
.bind(uid.0)
|
||||
.bind(UpdateAction::Update)
|
||||
.bind(now)
|
||||
.execute(&mut tx)
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
.last_insert_rowid();
|
||||
|
||||
tx.commit().await.map_err(InternalServerError)?;
|
||||
|
||||
// update cache
|
||||
cached_user.avatar_updated_at = now;
|
||||
|
||||
// broadcast event
|
||||
let _ = state
|
||||
.event_sender
|
||||
.send(Arc::new(BroadcastEvent::UserLog(UserUpdateLog {
|
||||
log_id,
|
||||
action: UpdateAction::Update,
|
||||
uid: uid.0,
|
||||
email: None,
|
||||
name: None,
|
||||
gender: None,
|
||||
language: None,
|
||||
is_admin: None,
|
||||
is_bot: None,
|
||||
avatar_updated_at: Some(now),
|
||||
})));
|
||||
|
||||
Ok(UploadAvatarApiResponse::Ok)
|
||||
}
|
||||
|
||||
/// Create a bot api-key
|
||||
#[oai(path = "/bot-api-key/:uid", method = "post")]
|
||||
async fn create_bot_api_key(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
uid: Path<i64>,
|
||||
req: Json<CreateBotApiKeyRequest>,
|
||||
) -> Result<CreateBotApiKeyResponse> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let mut cache = state.cache.write().await;
|
||||
let user = cache
|
||||
.users
|
||||
.get_mut(&uid)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
if user
|
||||
.bot_keys
|
||||
.values()
|
||||
.any(|bot_key| bot_key.name == req.name)
|
||||
{
|
||||
return Ok(CreateBotApiKeyResponse::ConflictName);
|
||||
}
|
||||
|
||||
let api_key = create_api_key(uid.0, &state.0.key_config.read().await.server_key);
|
||||
|
||||
// update sqlite
|
||||
let now = DateTime::now();
|
||||
let key_id =
|
||||
sqlx::query("insert into `bot_key` (uid, name, key, created_at) values (?, ?, ?, ?)")
|
||||
.bind(uid.0)
|
||||
.bind(&req.name)
|
||||
.bind(&api_key)
|
||||
.bind(now)
|
||||
.execute(&state.db_pool)
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
.last_insert_rowid();
|
||||
|
||||
// update cache
|
||||
user.bot_keys.insert(
|
||||
key_id,
|
||||
BotKey {
|
||||
name: req.0.name,
|
||||
key: api_key.clone(),
|
||||
created_at: now,
|
||||
last_used: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(CreateBotApiKeyResponse::Ok(Json(api_key)))
|
||||
}
|
||||
|
||||
/// Delete a bot api-key
|
||||
#[oai(path = "/bot-api-key/:uid/:kid", method = "delete")]
|
||||
async fn delete_bot_api_key(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
uid: Path<i64>,
|
||||
kid: Path<i64>,
|
||||
) -> Result<DeleteBotApiKeyResponse> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let mut cache = state.cache.write().await;
|
||||
let user = cache
|
||||
.users
|
||||
.get_mut(&uid)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
if !user.bot_keys.contains_key(&kid) {
|
||||
return Ok(DeleteBotApiKeyResponse::KeyNotFound);
|
||||
}
|
||||
|
||||
// update sqlite
|
||||
sqlx::query("delete from `bot_key` where id = ?")
|
||||
.bind(kid.0)
|
||||
.execute(&state.db_pool)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
// update cache
|
||||
user.bot_keys.remove(&kid);
|
||||
Ok(DeleteBotApiKeyResponse::Ok)
|
||||
}
|
||||
|
||||
/// List bot api-key
|
||||
#[oai(path = "/bot-api-key/:uid", method = "get")]
|
||||
async fn list_bot_api_key(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
uid: Path<i64>,
|
||||
) -> Result<Json<Vec<BotKeyInfo>>> {
|
||||
if !token.is_admin {
|
||||
return Err(Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
let cache = state.cache.read().await;
|
||||
let user = cache
|
||||
.users
|
||||
.get(&uid)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
Ok(Json(
|
||||
user.bot_keys
|
||||
.iter()
|
||||
.map(|(kid, bot_key)| BotKeyInfo {
|
||||
id: *kid,
|
||||
name: bot_key.name.clone(),
|
||||
key: bot_key.key.clone(),
|
||||
created_at: bot_key.created_at,
|
||||
last_used: bot_key.last_used,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use poem::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::test_harness::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_user() {
|
||||
let server = TestServer::new().await;
|
||||
|
||||
let admin_token = server.login_admin().await;
|
||||
let uid = server.create_user(&admin_token, "test1@voce.chat").await;
|
||||
let token = server.login("test1@voce.chat").await;
|
||||
let current_user = server.parse_token(token).await;
|
||||
assert_eq!(uid, current_user.uid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_name_conflict() {
|
||||
let server = TestServer::new().await;
|
||||
|
||||
let admin_token = server.login_admin().await;
|
||||
let resp = server
|
||||
.post("/api/admin/user")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({
|
||||
"email": "user1@voce.chat",
|
||||
"password": "123456",
|
||||
"name": "admin",
|
||||
"gender": 1,
|
||||
"language": "en-US",
|
||||
"is_admin": false,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status(StatusCode::CONFLICT);
|
||||
resp.assert_json(json!({
|
||||
"reason": "name_conflict"
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_email_conflict() {
|
||||
let server = TestServer::new().await;
|
||||
|
||||
let admin_token = server.login_admin().await;
|
||||
let resp = server
|
||||
.post("/api/admin/user")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({
|
||||
"email": "admin@voce.chat",
|
||||
"password": "123456",
|
||||
"name": "test1",
|
||||
"gender": 1,
|
||||
"language": "en-US",
|
||||
"is_admin": false,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status(StatusCode::CONFLICT);
|
||||
resp.assert_json(json!({
|
||||
"reason": "email_conflict"
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_user() {
|
||||
let server = TestServer::new().await;
|
||||
|
||||
let admin_token = server.login_admin().await;
|
||||
let uid = server.create_user(&admin_token, "test1@voce.chat").await;
|
||||
|
||||
let resp = server
|
||||
.delete(format!("/api/admin/user/{}", uid))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
server
|
||||
.get(format!("/api/admin/user/{}", uid))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await
|
||||
.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_user_info() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
let uid1 = server.create_user(&admin_token, "test1@voce.chat").await;
|
||||
|
||||
let resp = server
|
||||
.put(format!("/api/admin/user/{}", uid1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({ "email": "test2@voce.chat", "name": "test1", "gender": 2 }))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
let resp = server
|
||||
.get(format!("/api/admin/user/{}", uid1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let json = resp.json().await;
|
||||
json.value()
|
||||
.object()
|
||||
.get("email")
|
||||
.assert_string("test2@voce.chat");
|
||||
json.value().object().get("name").assert_string("test1");
|
||||
json.value().object().get("gender").assert_i64(2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_user_password() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
let uid1 = server.create_user(&admin_token, "test1@voce.chat").await;
|
||||
|
||||
fn make_login_body(password: &str) -> Value {
|
||||
json!({
|
||||
"credential": {
|
||||
"type": "password",
|
||||
"email": "test1@voce.chat",
|
||||
"password": password,
|
||||
},
|
||||
"device": "iphone",
|
||||
"device_token": "test",
|
||||
})
|
||||
}
|
||||
|
||||
server
|
||||
.post("/api/token/login")
|
||||
.body_json(&make_login_body("123456"))
|
||||
.send()
|
||||
.await
|
||||
.assert_status_is_ok();
|
||||
|
||||
let resp = server
|
||||
.put(format!("/api/admin/user/{}", uid1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({ "password": "654321" }))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
server
|
||||
.post("/api/token/login")
|
||||
.body_json(&make_login_body("123456"))
|
||||
.send()
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
|
||||
server
|
||||
.post("/api/token/login")
|
||||
.body_json(&make_login_body("654321"))
|
||||
.send()
|
||||
.await
|
||||
.assert_status_is_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_user_then_delete_owned_private_group() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
let uid1 = server.create_user(&admin_token, "test1@voce.chat").await;
|
||||
let token1 = server.login("test1@voce.chat").await;
|
||||
let mut gid_list = Vec::new();
|
||||
|
||||
for _ in 0..10 {
|
||||
// create group
|
||||
let resp = server
|
||||
.post("/api/group")
|
||||
.header("X-API-Key", &token1)
|
||||
.body_json(&json!({
|
||||
"name": "test",
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
gid_list.push(resp.json().await.value().object().get("gid").i64());
|
||||
}
|
||||
|
||||
server
|
||||
.delete(format!("/api/admin/user/{}", uid1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await
|
||||
.assert_status_is_ok();
|
||||
|
||||
for gid in gid_list {
|
||||
// check group
|
||||
let resp = server
|
||||
.get(format!("/api/group/{}", gid))
|
||||
.header("X-API-Key", &token1)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use poem::error::{InternalServerError, NotFound};
|
||||
use poem_openapi::Object;
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
get_merged_message, message::MergedMessagePayload, DateTime, FileMeta, MessageTarget,
|
||||
MessageTargetGroup, MessageTargetUser,
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug, Object, Clone, Serialize, Deserialize)]
|
||||
pub struct Archive {
|
||||
pub users: Vec<ArchiveUser>,
|
||||
pub messages: Vec<ArchiveMessage>,
|
||||
pub num_attachments: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object, Clone, Serialize, Deserialize)]
|
||||
pub struct ArchiveUser {
|
||||
pub name: String,
|
||||
pub avatar: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object, Clone, Serialize, Deserialize)]
|
||||
pub struct ArchiveMessage {
|
||||
pub from_user: usize,
|
||||
pub created_at: DateTime,
|
||||
pub mid: i64,
|
||||
pub source: MessageTarget,
|
||||
#[oai(flatten)]
|
||||
pub content: ArchiveMessageContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object, Clone, Serialize, Deserialize)]
|
||||
pub struct ArchiveMessageContent {
|
||||
pub properties: Option<HashMap<String, Value>>,
|
||||
pub content_type: String,
|
||||
pub content: Option<String>,
|
||||
pub file_id: Option<usize>,
|
||||
pub thumbnail_id: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ArchiveFile {
|
||||
pub content_type: String,
|
||||
pub filename: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub content: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn internal_create_message_archive(
|
||||
state: &State,
|
||||
mut mid_list: Vec<i64>,
|
||||
create_by_uid: i64,
|
||||
) -> poem::Result<(Archive, Vec<ArchiveFile>)> {
|
||||
let cache = state.cache.read().await;
|
||||
let is_admin = cache
|
||||
.users
|
||||
.get(&create_by_uid)
|
||||
.ok_or_else(|| poem::Error::from_status(StatusCode::BAD_REQUEST))?
|
||||
.is_admin;
|
||||
|
||||
let mut archive = Archive {
|
||||
users: Vec::new(),
|
||||
messages: Vec::new(),
|
||||
num_attachments: 0,
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
let mut users_map: HashMap<i64, usize> = Default::default();
|
||||
|
||||
mid_list.sort_unstable();
|
||||
|
||||
for mid in mid_list {
|
||||
let merged_payload = get_merged_message(&state.msg_db, mid)?
|
||||
.ok_or_else(|| poem::Error::from_status(StatusCode::NOT_FOUND))?;
|
||||
|
||||
if !is_admin {
|
||||
let allow = match merged_payload.target {
|
||||
MessageTarget::User(MessageTargetUser { uid })
|
||||
if uid == create_by_uid || merged_payload.from_uid == create_by_uid =>
|
||||
{
|
||||
true
|
||||
}
|
||||
MessageTarget::Group(MessageTargetGroup { gid }) => cache
|
||||
.groups
|
||||
.get(&gid)
|
||||
.map(|group| group.contains_user(create_by_uid))
|
||||
.unwrap_or_default(),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !allow {
|
||||
return Err(poem::Error::from_status(StatusCode::FORBIDDEN));
|
||||
}
|
||||
}
|
||||
|
||||
let from_user = cache
|
||||
.users
|
||||
.get(&merged_payload.from_uid)
|
||||
.ok_or_else(|| poem::Error::from_status(StatusCode::BAD_REQUEST))?;
|
||||
|
||||
let user_idx = match users_map.get(&merged_payload.from_uid) {
|
||||
Some(idx) => *idx,
|
||||
None => {
|
||||
let avatar_path = state
|
||||
.config
|
||||
.system
|
||||
.avatar_dir()
|
||||
.join(format!("{}.png", merged_payload.from_uid));
|
||||
let avatar_data = tokio::fs::read(avatar_path).await.ok();
|
||||
let avatar = avatar_data.map(|avatar_data| {
|
||||
files.push(ArchiveFile {
|
||||
content_type: "image/png".to_string(),
|
||||
filename: None,
|
||||
content: avatar_data,
|
||||
});
|
||||
files.len() - 1
|
||||
});
|
||||
|
||||
archive.users.push(ArchiveUser {
|
||||
name: from_user.name.clone(),
|
||||
avatar,
|
||||
});
|
||||
users_map.insert(merged_payload.from_uid, archive.users.len() - 1);
|
||||
archive.users.len() - 1
|
||||
}
|
||||
};
|
||||
|
||||
match merged_payload.content.content_type.as_str() {
|
||||
"text/plain" | "text/markdown" => {
|
||||
archive.messages.push(ArchiveMessage {
|
||||
from_user: user_idx,
|
||||
created_at: merged_payload.created_at,
|
||||
mid,
|
||||
source: get_source(create_by_uid, &merged_payload),
|
||||
content: ArchiveMessageContent {
|
||||
properties: merged_payload.content.properties.clone(),
|
||||
content_type: merged_payload.content.content_type.clone(),
|
||||
content: Some(merged_payload.content.content.clone()),
|
||||
file_id: None,
|
||||
thumbnail_id: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
"vocechat/file" => {
|
||||
let file_path = state
|
||||
.config
|
||||
.system
|
||||
.file_dir()
|
||||
.join(&merged_payload.content.content);
|
||||
let file_data = tokio::fs::read(&file_path).await.unwrap_or_default();
|
||||
|
||||
let thumbnail_path = state
|
||||
.config
|
||||
.system
|
||||
.thumbnail_dir()
|
||||
.join(&merged_payload.content.content);
|
||||
let thumbnail_data = tokio::fs::read(&thumbnail_path).await.ok();
|
||||
|
||||
let meta = tokio::fs::read(file_path.with_extension("meta"))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|data| serde_json::from_slice::<FileMeta>(&data).ok())
|
||||
.unwrap_or_else(|| FileMeta {
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
filename: None,
|
||||
});
|
||||
|
||||
files.push(ArchiveFile {
|
||||
content_type: meta.content_type.clone(),
|
||||
filename: meta.filename.clone(),
|
||||
content: file_data,
|
||||
});
|
||||
let file_idx = files.len() - 1;
|
||||
|
||||
let thumbnail_id = if let Some(thumbnail_data) = thumbnail_data {
|
||||
files.push(ArchiveFile {
|
||||
content_type: meta.content_type,
|
||||
filename: meta.filename,
|
||||
content: thumbnail_data,
|
||||
});
|
||||
Some(files.len() - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
archive.messages.push(ArchiveMessage {
|
||||
from_user: user_idx,
|
||||
created_at: merged_payload.created_at,
|
||||
mid,
|
||||
source: get_source(create_by_uid, &merged_payload),
|
||||
content: ArchiveMessageContent {
|
||||
properties: merged_payload.content.properties.clone(),
|
||||
content_type: merged_payload.content.content_type.clone(),
|
||||
content: None,
|
||||
file_id: Some(file_idx),
|
||||
thumbnail_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
_ => return Err(poem::Error::from_status(StatusCode::BAD_REQUEST)),
|
||||
}
|
||||
}
|
||||
|
||||
archive.num_attachments = files.len();
|
||||
Ok((archive, files))
|
||||
}
|
||||
|
||||
fn get_source(create_by_uid: i64, merged_payload: &MergedMessagePayload) -> MessageTarget {
|
||||
match merged_payload.target {
|
||||
MessageTarget::User(MessageTargetUser { uid }) => {
|
||||
if uid == create_by_uid {
|
||||
MessageTarget::User(MessageTargetUser {
|
||||
uid: merged_payload.from_uid,
|
||||
})
|
||||
} else {
|
||||
MessageTarget::User(MessageTargetUser { uid })
|
||||
}
|
||||
}
|
||||
group @ MessageTarget::Group(MessageTargetGroup { .. }) => group,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_message_archive(
|
||||
state: &State,
|
||||
mid_list: Vec<i64>,
|
||||
create_by_uid: i64,
|
||||
) -> poem::Result<Vec<u8>> {
|
||||
let (archive, files) = internal_create_message_archive(state, mid_list, create_by_uid).await?;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
let mut zip_writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
|
||||
|
||||
let options =
|
||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
zip_writer
|
||||
.start_file("index.json", options)
|
||||
.map_err(InternalServerError)?;
|
||||
let index_data = serde_json::to_vec(&archive).map_err(InternalServerError)?;
|
||||
zip_writer.write(&index_data).map_err(InternalServerError)?;
|
||||
|
||||
for (idx, file) in files.into_iter().enumerate() {
|
||||
zip_writer
|
||||
.start_file(format!("attachment/{}.data", idx), options)
|
||||
.map_err(InternalServerError)?;
|
||||
zip_writer
|
||||
.write_all(&file.content)
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
let meta_data = serde_json::to_vec(&file).map_err(InternalServerError)?;
|
||||
zip_writer
|
||||
.start_file(format!("attachment/{}.meta", idx), options)
|
||||
.map_err(InternalServerError)?;
|
||||
zip_writer
|
||||
.write_all(&meta_data)
|
||||
.map_err(InternalServerError)?;
|
||||
}
|
||||
|
||||
zip_writer.finish().map_err(InternalServerError)?;
|
||||
drop(zip_writer);
|
||||
|
||||
Ok(buf)
|
||||
})
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
}
|
||||
|
||||
pub async fn extract_archive(path: impl Into<PathBuf>) -> poem::Result<Archive> {
|
||||
let path = path.into();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let file = File::open(path).map_err(NotFound)?;
|
||||
let mut zip_archive = zip::ZipArchive::new(file).map_err(InternalServerError)?;
|
||||
let mut index_file = zip_archive
|
||||
.by_name("index.json")
|
||||
.map_err(InternalServerError)?;
|
||||
let mut data = Vec::new();
|
||||
index_file
|
||||
.read_to_end(&mut data)
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
serde_json::from_slice(&data).map_err(InternalServerError)
|
||||
})
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
}
|
||||
|
||||
pub async fn extract_archive_attachment(
|
||||
path: impl Into<PathBuf>,
|
||||
file_idx: usize,
|
||||
) -> poem::Result<ArchiveFile> {
|
||||
let path = path.into();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !path.exists() {
|
||||
return Err(StatusCode::NOT_FOUND.into());
|
||||
}
|
||||
|
||||
let file = File::open(path).map_err(NotFound)?;
|
||||
let mut zip_archive = zip::ZipArchive::new(file).map_err(InternalServerError)?;
|
||||
|
||||
let mut archive_file = {
|
||||
let mut index_file = zip_archive
|
||||
.by_name(&format!("attachment/{}.meta", file_idx))
|
||||
.map_err(InternalServerError)?;
|
||||
let mut data = Vec::new();
|
||||
index_file
|
||||
.read_to_end(&mut data)
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
serde_json::from_slice::<ArchiveFile>(&data).map_err(InternalServerError)?
|
||||
};
|
||||
|
||||
archive_file.content = {
|
||||
let mut index_file = zip_archive
|
||||
.by_name(&format!("attachment/{}.data", file_idx))
|
||||
.map_err(InternalServerError)?;
|
||||
let mut data = Vec::new();
|
||||
index_file
|
||||
.read_to_end(&mut data)
|
||||
.map_err(InternalServerError)?;
|
||||
data
|
||||
};
|
||||
|
||||
Ok(archive_file)
|
||||
})
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+351
@@ -0,0 +1,351 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::Datelike;
|
||||
use futures_util::TryFutureExt;
|
||||
use mime_guess::{mime, Mime};
|
||||
use poem::{
|
||||
error::{BadRequest, InternalServerError},
|
||||
http::StatusCode,
|
||||
web::Data,
|
||||
Error, Result,
|
||||
};
|
||||
use poem_openapi::{
|
||||
param::{Header, Path, Query},
|
||||
payload::Json,
|
||||
OpenApi,
|
||||
};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
group::get_related_groups,
|
||||
message::{parse_properties_from_base64, send_message, SendMessageRequest},
|
||||
resource::{
|
||||
sha256_file, ImageProperties, PrepareUploadFileRequest, UploadFileRequest,
|
||||
UploadFileResponse,
|
||||
},
|
||||
tags::ApiTags,
|
||||
DateTime, FileMeta, Group, MessageTarget, UserInfo,
|
||||
},
|
||||
api_key::parse_api_key,
|
||||
state::State,
|
||||
};
|
||||
|
||||
async fn check_api_key(state: &State, uid: i64, key: &str) -> Result<()> {
|
||||
let mut cache = state.cache.write().await;
|
||||
let bot_key = cache
|
||||
.users
|
||||
.get_mut(&uid)
|
||||
.and_then(|user| {
|
||||
user.bot_keys
|
||||
.values_mut()
|
||||
.find(|bot_key| bot_key.key == key)
|
||||
})
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
bot_key.last_used = Some(DateTime::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct ApiBot;
|
||||
|
||||
#[OpenApi(prefix_path = "/bot", tag = "ApiTags::Bot")]
|
||||
impl ApiBot {
|
||||
/// Get all groups related to the current user.
|
||||
#[oai(path = "/", method = "get")]
|
||||
async fn get_related_groups(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
#[oai(name = "x-api-key")] api_key: Header<String>,
|
||||
public_only: Query<Option<bool>>,
|
||||
) -> Result<Json<Vec<Group>>> {
|
||||
let current_uid = parse_api_key(&api_key, &state.0.key_config.read().await.server_key)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
check_api_key(&state, current_uid, &api_key).await?;
|
||||
let cache = state.cache.read().await;
|
||||
Ok(Json(get_related_groups(
|
||||
&cache.groups,
|
||||
current_uid,
|
||||
public_only.0.unwrap_or_default(),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Send message to the specified user
|
||||
#[oai(path = "/send_to_user/:uid", method = "post")]
|
||||
async fn send_to_user(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
#[oai(name = "x-api-key")] api_key: Header<String>,
|
||||
uid: Path<i64>,
|
||||
#[oai(name = "X-Properties")] properties: Header<Option<String>>,
|
||||
req: SendMessageRequest,
|
||||
) -> Result<Json<i64>> {
|
||||
let current_uid = parse_api_key(&api_key, &state.0.key_config.read().await.server_key)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
check_api_key(&state, current_uid, &api_key).await?;
|
||||
let properties = parse_properties_from_base64(properties.0);
|
||||
let payload = req
|
||||
.into_chat_message_payload(&state, current_uid, MessageTarget::user(uid.0), properties)
|
||||
.await?;
|
||||
let mid = send_message(&state, payload).await?;
|
||||
Ok(Json(mid))
|
||||
}
|
||||
|
||||
/// Send message to the specified group
|
||||
#[oai(path = "/send_to_group/:gid", method = "post")]
|
||||
async fn send_to_group(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
#[oai(name = "x-api-key")] api_key: Header<String>,
|
||||
gid: Path<i64>,
|
||||
#[oai(name = "X-Properties")] properties: Header<Option<String>>,
|
||||
req: SendMessageRequest,
|
||||
) -> Result<Json<i64>> {
|
||||
let current_uid = parse_api_key(&api_key, &state.0.key_config.read().await.server_key)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
check_api_key(&state, current_uid, &api_key).await?;
|
||||
let properties = parse_properties_from_base64(properties.0);
|
||||
let payload = req
|
||||
.into_chat_message_payload(&state, current_uid, MessageTarget::group(gid.0), properties)
|
||||
.await?;
|
||||
let mid = send_message(&state, payload).await?;
|
||||
Ok(Json(mid))
|
||||
}
|
||||
|
||||
/// Get user info by id
|
||||
#[oai(path = "/user/:uid", method = "get")]
|
||||
async fn user_info(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
#[oai(name = "x-api-key")] api_key: Header<String>,
|
||||
uid: Query<i64>,
|
||||
) -> Result<Json<UserInfo>> {
|
||||
let current_uid = parse_api_key(&api_key, &state.0.key_config.read().await.server_key)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
check_api_key(&state, current_uid, &api_key).await?;
|
||||
let cache = state.cache.read().await;
|
||||
let user = cache
|
||||
.users
|
||||
.get(&uid.0)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?;
|
||||
Ok(Json(user.api_user_info(uid.0)))
|
||||
}
|
||||
|
||||
/// Get group info by id
|
||||
#[oai(path = "/group/:gid", method = "get")]
|
||||
async fn group_info(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
#[oai(name = "x-api-key")] api_key: Header<String>,
|
||||
gid: Query<i64>,
|
||||
) -> Result<Json<Group>> {
|
||||
let current_uid = parse_api_key(&api_key, &state.0.key_config.read().await.server_key)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
check_api_key(&state, current_uid, &api_key).await?;
|
||||
let cache = state.cache.read().await;
|
||||
let group = cache
|
||||
.groups
|
||||
.get(&gid.0)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?;
|
||||
Ok(Json(group.api_group(gid.0)))
|
||||
}
|
||||
|
||||
/// Prepare for uploading file
|
||||
#[oai(path = "/file/prepare", method = "post")]
|
||||
async fn upload_file_prepare(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
req: Json<PrepareUploadFileRequest>,
|
||||
#[oai(name = "x-api-key")] api_key: Header<String>,
|
||||
) -> Result<Json<String>> {
|
||||
let current_uid = parse_api_key(&api_key, &state.0.key_config.read().await.server_key)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
check_api_key(&state, current_uid, &api_key).await?;
|
||||
|
||||
let uuid = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let tmp_file_path = state
|
||||
.config
|
||||
.system
|
||||
.tmp_dir()
|
||||
.join(&uuid)
|
||||
.with_extension("data");
|
||||
let tmp_file_path_meta = tmp_file_path.with_extension("meta");
|
||||
|
||||
tokio::fs::write(&tmp_file_path, &[])
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
let meta = FileMeta {
|
||||
content_type: match req.0.content_type {
|
||||
Some(content_type) => content_type,
|
||||
None => match &req.filename {
|
||||
Some(filename) => mime_guess::from_path(filename)
|
||||
.first()
|
||||
.map(|mime| mime.to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
None => "application/octet-stream".to_string(),
|
||||
},
|
||||
},
|
||||
filename: req.0.filename,
|
||||
};
|
||||
tokio::fs::write(
|
||||
&tmp_file_path_meta,
|
||||
serde_json::to_vec(&meta).map_err(InternalServerError)?,
|
||||
)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
Ok(Json(uuid))
|
||||
}
|
||||
|
||||
/// Upload file
|
||||
#[oai(path = "/file/upload", method = "post")]
|
||||
async fn upload_file(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
#[oai(name = "x-api-key")] api_key: Header<String>,
|
||||
req: UploadFileRequest,
|
||||
) -> Result<Json<Option<UploadFileResponse>>> {
|
||||
let current_uid = parse_api_key(&api_key, &state.0.key_config.read().await.server_key)
|
||||
.ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
check_api_key(&state, current_uid, &api_key).await?;
|
||||
|
||||
if req.file_id.is_empty() {
|
||||
return Err(Error::from_status(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
// open the file, append data.
|
||||
let tmp_file_path = state
|
||||
.config
|
||||
.system
|
||||
.tmp_dir()
|
||||
.join(&req.file_id)
|
||||
.with_extension("data");
|
||||
let tmp_file_path_meta = tmp_file_path.with_extension("meta");
|
||||
|
||||
if tmp_file_path.exists() {
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&tmp_file_path)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
f.write_all(req.chunk_data.as_slice())
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
f.flush().await.map_err(InternalServerError)?;
|
||||
} else {
|
||||
return Err(Error::from_status(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
if req.chunk_is_last {
|
||||
let file_meta = serde_json::from_slice::<FileMeta>(
|
||||
&std::fs::read(&tmp_file_path_meta).map_err(InternalServerError)?,
|
||||
)
|
||||
.map_err(InternalServerError)?;
|
||||
let is_image = Mime::from_str(&file_meta.content_type)
|
||||
.map(|mime| mime.type_() == mime::IMAGE)
|
||||
.unwrap_or_default();
|
||||
|
||||
let file_size = tokio::fs::metadata(&tmp_file_path)
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
.len() as i64;
|
||||
let file_hash = sha256_file(&tmp_file_path)
|
||||
.await
|
||||
.map_err(|_| Error::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||||
|
||||
// move tmp to dir
|
||||
let now = DateTime::now();
|
||||
let year = now.year();
|
||||
let month = now.month();
|
||||
let day = now.day();
|
||||
// ./data/file/{year}/{month}/{day}/{file_uuid}
|
||||
// ./data/file/2021/01/30/123-456-789
|
||||
let save_path = state
|
||||
.config
|
||||
.system
|
||||
.file_dir()
|
||||
.join(year.to_string())
|
||||
.join(month.to_string())
|
||||
.join(day.to_string());
|
||||
if !save_path.exists() {
|
||||
tokio::fs::create_dir_all(&save_path)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
}
|
||||
|
||||
tokio::fs::rename(&tmp_file_path, save_path.join(&req.file_id))
|
||||
.and_then(|_| {
|
||||
tokio::fs::rename(
|
||||
&tmp_file_path_meta,
|
||||
save_path.join(&req.file_id).with_extension("meta"),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
// create the thumbnail
|
||||
let mut image_properties = None;
|
||||
if is_image {
|
||||
let src_path = save_path.join(&req.file_id);
|
||||
let src_meta_path = src_path.with_extension("meta");
|
||||
let thumbnail_dir_path = state
|
||||
.config
|
||||
.system
|
||||
.thumbnail_dir()
|
||||
.join(year.to_string())
|
||||
.join(month.to_string())
|
||||
.join(day.to_string());
|
||||
let thumbnail_file_path = thumbnail_dir_path.join(&req.file_id);
|
||||
let thumbnail_meta_path = thumbnail_file_path.with_extension("meta");
|
||||
|
||||
tracing::info!(
|
||||
src_path = %src_path.display(),
|
||||
thumbnail_path = %thumbnail_file_path.display(),
|
||||
"create thumbnail",
|
||||
);
|
||||
|
||||
image_properties = Some(
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let image_data = std::fs::read(src_path).map_err(InternalServerError)?;
|
||||
let image = image::load_from_memory(&image_data).map_err(BadRequest)?;
|
||||
let thumbnail = image.thumbnail(480, 480);
|
||||
let _ = std::fs::create_dir_all(&thumbnail_dir_path);
|
||||
|
||||
if thumbnail.color().has_alpha() {
|
||||
thumbnail
|
||||
.save_with_format(thumbnail_file_path, image::ImageFormat::Png)
|
||||
.map_err(InternalServerError)?;
|
||||
} else {
|
||||
thumbnail
|
||||
.save_with_format(thumbnail_file_path, image::ImageFormat::Jpeg)
|
||||
.map_err(InternalServerError)?;
|
||||
}
|
||||
|
||||
std::fs::copy(&src_meta_path, &thumbnail_meta_path)
|
||||
.map_err(InternalServerError)?;
|
||||
Ok::<_, poem::Error>(ImageProperties {
|
||||
width: image.width(),
|
||||
height: image.height(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(InternalServerError)??,
|
||||
);
|
||||
}
|
||||
|
||||
// return the file url path, the client assembles the payload and sends it.
|
||||
// {year}/{month}/{day}/{file_uuid}
|
||||
// 2021/01/30/123-456-789
|
||||
let file_url_path = format!("{}/{}/{}/{}", year, month, day, &req.file_id);
|
||||
return Ok(Json(Some(UploadFileResponse {
|
||||
path: file_url_path,
|
||||
size: file_size,
|
||||
hash: file_hash.to_string(),
|
||||
image_properties,
|
||||
})));
|
||||
}
|
||||
Ok(Json(None))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::{borrow::Cow, ops::Deref};
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use poem_openapi::registry::{MetaSchema, MetaSchemaRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
pub struct DateTime(pub chrono::DateTime<Utc>);
|
||||
|
||||
impl DateTime {
|
||||
#[inline]
|
||||
pub fn now() -> Self {
|
||||
Self(Utc::now())
|
||||
}
|
||||
|
||||
pub fn zero() -> Self {
|
||||
Self(Utc.timestamp_millis_opt(0).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::DateTime<Utc>> for DateTime {
|
||||
fn from(datetime: chrono::DateTime<Utc>) -> Self {
|
||||
Self(datetime)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime> for chrono::DateTime<Utc> {
|
||||
fn from(datetime: DateTime) -> Self {
|
||||
datetime.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DateTime {
|
||||
type Target = chrono::DateTime<Utc>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DateTime {
|
||||
fn default() -> Self {
|
||||
Self(Utc::now())
|
||||
}
|
||||
}
|
||||
|
||||
impl sqlx::Type<sqlx::Sqlite> for DateTime {
|
||||
fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
|
||||
<chrono::DateTime<Utc>>::type_info()
|
||||
}
|
||||
|
||||
fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
|
||||
<chrono::DateTime<Utc>>::compatible(ty)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sqlx::Decode<'a, sqlx::Sqlite> for DateTime {
|
||||
fn decode(value: sqlx::sqlite::SqliteValueRef<'a>) -> Result<Self, sqlx::error::BoxDynError> {
|
||||
<chrono::DateTime<Utc>>::decode(value).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sqlx::Encode<'a, sqlx::Sqlite> for DateTime {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
buf: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'a>>,
|
||||
) -> sqlx::encode::IsNull {
|
||||
self.0.encode(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl poem_openapi::types::Type for DateTime {
|
||||
const IS_REQUIRED: bool = true;
|
||||
type RawValueType = Self;
|
||||
type RawElementValueType = Self;
|
||||
|
||||
fn name() -> Cow<'static, str> {
|
||||
"integer(timestamp)".into()
|
||||
}
|
||||
|
||||
fn schema_ref() -> poem_openapi::registry::MetaSchemaRef {
|
||||
MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format(
|
||||
"integer",
|
||||
"timestamp",
|
||||
)))
|
||||
}
|
||||
|
||||
fn as_raw_value(&self) -> Option<&Self::RawValueType> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn raw_element_iter<'a>(
|
||||
&'a self,
|
||||
) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> {
|
||||
Box::new(self.as_raw_value().into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl poem_openapi::types::ParseFromJSON for DateTime {
|
||||
fn parse_from_json(value: Option<Value>) -> poem_openapi::types::ParseResult<Self> {
|
||||
i64::parse_from_json(value)
|
||||
.map(|timestamp| Self(Utc.timestamp_millis_opt(timestamp).unwrap()))
|
||||
.map_err(poem_openapi::types::ParseError::propagate)
|
||||
}
|
||||
}
|
||||
|
||||
impl poem_openapi::types::ToJSON for DateTime {
|
||||
fn to_json(&self) -> Option<Value> {
|
||||
Some(self.0.timestamp_millis().into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use poem::{
|
||||
error::InternalServerError,
|
||||
http::{header, StatusCode},
|
||||
web::Data,
|
||||
Error, Result,
|
||||
};
|
||||
use poem_openapi::{
|
||||
param::{Path, Query},
|
||||
payload::{Binary, Json, Response},
|
||||
ApiResponse, Object, OpenApi,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
archive::{create_message_archive, extract_archive, extract_archive_attachment},
|
||||
tags::ApiTags,
|
||||
token::Token,
|
||||
Archive, DateTime,
|
||||
},
|
||||
middleware::guest_forbidden,
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug, Object)]
|
||||
struct FavoriteArchive {
|
||||
id: String,
|
||||
created_at: DateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object)]
|
||||
struct CreateFavoriteRequest {
|
||||
mid_list: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ApiResponse)]
|
||||
enum CreateFavoriteResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<FavoriteArchive>),
|
||||
/// Too many favorite archives
|
||||
#[oai(status = 429)]
|
||||
TooManyFavorites,
|
||||
}
|
||||
|
||||
pub struct ApiFavorite;
|
||||
|
||||
#[OpenApi(prefix_path = "/favorite", tag = "ApiTags::Favorite")]
|
||||
impl ApiFavorite {
|
||||
/// Create favorite archive
|
||||
#[oai(path = "/", method = "post", transform = "guest_forbidden")]
|
||||
async fn create(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
req: Json<CreateFavoriteRequest>,
|
||||
) -> Result<CreateFavoriteResponse> {
|
||||
let sql = "select count(*) from favorite_archive where uid = ?";
|
||||
let count = sqlx::query_as::<_, (i64,)>(sql)
|
||||
.fetch_one(&state.db_pool)
|
||||
.await
|
||||
.map(|(count,)| count)
|
||||
.map_err(InternalServerError)?;
|
||||
if count >= state.config.system.max_favorite_archives as i64 {
|
||||
return Ok(CreateFavoriteResponse::TooManyFavorites);
|
||||
}
|
||||
|
||||
let data = create_message_archive(&state, req.0.mid_list, token.uid).await?;
|
||||
let path = state.config.system.favorite_dir(token.uid);
|
||||
let uuid = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let _ = std::fs::create_dir_all(&path);
|
||||
tokio::fs::write(path.join(&uuid), data)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
let now = DateTime::now();
|
||||
let sql = "insert into favorite_archive (uid, archive_id, created_at) values (?, ?, ?)";
|
||||
sqlx::query(sql)
|
||||
.bind(token.uid)
|
||||
.bind(&uuid)
|
||||
.bind(now)
|
||||
.execute(&state.db_pool)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
Ok(CreateFavoriteResponse::Ok(Json(FavoriteArchive {
|
||||
id: uuid,
|
||||
created_at: now,
|
||||
})))
|
||||
}
|
||||
|
||||
/// List all favorite archives
|
||||
#[oai(path = "/", method = "get")]
|
||||
async fn list(&self, state: Data<&State>, token: Token) -> Result<Json<Vec<FavoriteArchive>>> {
|
||||
let sql = "select archive_id, created_at from favorite_archive where uid = ?";
|
||||
let mut archives = sqlx::query_as::<_, (String, DateTime)>(sql)
|
||||
.bind(token.uid)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
.map_err(InternalServerError)?
|
||||
.into_iter()
|
||||
.map(|(archive_id, created_at)| FavoriteArchive {
|
||||
id: archive_id,
|
||||
created_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
archives.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Ok(Json(archives))
|
||||
}
|
||||
|
||||
/// Delete a favorite archive
|
||||
#[oai(path = "/:id", method = "delete", transform = "guest_forbidden")]
|
||||
async fn delete(&self, state: Data<&State>, token: Token, id: Path<String>) -> Result<()> {
|
||||
let path = state.config.system.favorite_dir(token.uid).join(&id.0);
|
||||
if !path.exists() {
|
||||
return Err(Error::from_status(StatusCode::NOT_FOUND));
|
||||
}
|
||||
|
||||
sqlx::query("delete from favorite_archive where uid = ? and archive_id = ?")
|
||||
.bind(token.uid)
|
||||
.bind(&id.0)
|
||||
.execute(&state.db_pool)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
tokio::fs::remove_file(&path)
|
||||
.await
|
||||
.map_err(InternalServerError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get favorite archive info
|
||||
#[oai(path = "/:id", method = "get")]
|
||||
async fn get_index(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
id: Path<String>,
|
||||
) -> Result<Response<Json<Archive>>> {
|
||||
if id.contains('.') {
|
||||
return Err(Error::from_status(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
let path = state.config.system.favorite_dir(token.uid).join(&id.0);
|
||||
let archive = extract_archive(path).await?;
|
||||
Ok(Response::new(Json(archive)).header(header::CACHE_CONTROL, "public, max-age=31536000"))
|
||||
}
|
||||
|
||||
/// Get attachment in the archive
|
||||
#[oai(path = "/attachment/:uid/:id/:attachment_id", method = "get")]
|
||||
async fn get_archive_attachment(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
uid: Path<i64>,
|
||||
id: Path<String>,
|
||||
attachment_id: Path<usize>,
|
||||
#[oai(default)] download: Query<bool>,
|
||||
) -> Result<Response<Binary<Vec<u8>>>> {
|
||||
if id.contains('.') {
|
||||
return Err(Error::from_status(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
let path = state.config.system.favorite_dir(uid.0).join(&id.0);
|
||||
let archive_file = extract_archive_attachment(path, attachment_id.0).await?;
|
||||
|
||||
let mut resp = Response::new(Binary(archive_file.content));
|
||||
let ty = if download.0 { "attachment" } else { "inline" };
|
||||
resp = resp
|
||||
.header(header::CONTENT_TYPE, archive_file.content_type)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000");
|
||||
if let Some(filename) = archive_file.filename {
|
||||
resp = resp.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!(r#"{}; filename="{}""#, ty, filename),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_harness::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_crud() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
|
||||
let user1 = server.create_user(&admin_token, "user1@voce.chat").await;
|
||||
server.login("user1@voce.chat").await;
|
||||
|
||||
let mid1 = server.send_text_to_user(&admin_token, user1, "a").await;
|
||||
let mid2 = server.send_text_to_user(&admin_token, user1, "b").await;
|
||||
let mid3 = server.send_text_to_user(&admin_token, user1, "c").await;
|
||||
|
||||
let resp = server
|
||||
.post("/api/favorite")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({
|
||||
"mid_list": [mid1, mid2],
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let json = resp.json().await;
|
||||
let archive1 = json.value().object().get("id").string().to_string();
|
||||
|
||||
let resp = server
|
||||
.post("/api/favorite")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({
|
||||
"mid_list": [mid2, mid3],
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let json = resp.json().await;
|
||||
let archive2 = json.value().object().get("id").string().to_string();
|
||||
|
||||
let resp = server
|
||||
.get("/api/favorite")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let json = resp.json().await;
|
||||
let archives = json.value().array();
|
||||
|
||||
archives.assert_len(2);
|
||||
archives.get(0).object().get("id").assert_string(&archive1);
|
||||
archives.get(1).object().get("id").assert_string(&archive2);
|
||||
|
||||
let resp = server
|
||||
.get(format!("/api/favorite/{}", archive1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let json = resp.json().await;
|
||||
let json = json.value().object();
|
||||
|
||||
let users = json.get("users").array();
|
||||
users.assert_len(1);
|
||||
users.get(0).object().get("name").assert_string("admin");
|
||||
|
||||
let messages = json.get("messages").array();
|
||||
messages.assert_len(2);
|
||||
|
||||
let message = messages.get(0).object();
|
||||
message.get("from_user").assert_i64(0);
|
||||
message.get("content_type").assert_string("text/plain");
|
||||
message.get("content").assert_string("a");
|
||||
|
||||
let message = messages.get(1).object();
|
||||
message.get("from_user").assert_i64(0);
|
||||
message.get("content_type").assert_string("text/plain");
|
||||
message.get("content").assert_string("b");
|
||||
|
||||
json.get("num_attachments").assert_i64(0);
|
||||
|
||||
let resp = server
|
||||
.delete(format!("/api/favorite/{}", archive1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
let resp = server
|
||||
.get("/api/favorite")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let json = resp.json().await;
|
||||
json.value().array().assert_len(1);
|
||||
}
|
||||
}
|
||||
+2251
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt::{Display, Formatter},
|
||||
ops::Deref,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use poem_openapi::registry::{MetaSchema, MetaSchemaRef};
|
||||
use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use sqlx::sqlite::SqliteArgumentValue;
|
||||
|
||||
/// Language id
|
||||
///
|
||||
/// Reference: <http://tools.ietf.org/html/bcp47>
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LangId(unic_langid::LanguageIdentifier);
|
||||
|
||||
impl Default for LangId {
|
||||
fn default() -> Self {
|
||||
Self("en-US".parse().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for LangId {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for LangId {
|
||||
type Err = unic_langid::LanguageIdentifierError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
unic_langid::LanguageIdentifier::from_str(s).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for LangId {
|
||||
type Target = unic_langid::LanguageIdentifier;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl sqlx::Type<sqlx::Sqlite> for LangId {
|
||||
fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
|
||||
String::type_info()
|
||||
}
|
||||
|
||||
fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
|
||||
String::compatible(ty)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sqlx::Decode<'a, sqlx::Sqlite> for LangId {
|
||||
fn decode(value: sqlx::sqlite::SqliteValueRef<'a>) -> Result<Self, sqlx::error::BoxDynError> {
|
||||
let s = String::decode(value)?;
|
||||
let language_tag: unic_langid::LanguageIdentifier = s.parse().map_err(Box::new)?;
|
||||
Ok(Self(language_tag))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sqlx::Encode<'a, sqlx::Sqlite> for LangId {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
buf: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'a>>,
|
||||
) -> sqlx::encode::IsNull {
|
||||
buf.push(SqliteArgumentValue::Text(Cow::Owned(self.0.to_string())));
|
||||
sqlx::encode::IsNull::No
|
||||
}
|
||||
}
|
||||
|
||||
impl poem_openapi::types::Type for LangId {
|
||||
const IS_REQUIRED: bool = true;
|
||||
type RawValueType = Self;
|
||||
type RawElementValueType = Self;
|
||||
|
||||
fn name() -> Cow<'static, str> {
|
||||
Cow::Borrowed("string(language)")
|
||||
}
|
||||
|
||||
fn schema_ref() -> MetaSchemaRef {
|
||||
MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("string", "language")))
|
||||
}
|
||||
|
||||
fn as_raw_value(&self) -> Option<&Self::RawValueType> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn raw_element_iter<'a>(
|
||||
&'a self,
|
||||
) -> Box<dyn Iterator<Item = &'a Self::RawElementValueType> + 'a> {
|
||||
Box::new(self.as_raw_value().into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl poem_openapi::types::ParseFromJSON for LangId {
|
||||
fn parse_from_json(value: Option<Value>) -> poem_openapi::types::ParseResult<Self> {
|
||||
let value = value.unwrap_or_default();
|
||||
match &value {
|
||||
Value::String(s) => unic_langid::LanguageIdentifier::from_str(s)
|
||||
.map(Self)
|
||||
.map_err(poem_openapi::types::ParseError::custom),
|
||||
_ => Err(poem_openapi::types::ParseError::expected_type(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl poem_openapi::types::ToJSON for LangId {
|
||||
fn to_json(&self) -> Option<Value> {
|
||||
Some(Value::String(self.0.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for LangId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for LangId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
LangId::from_str(&s).map_err(|err| <D::Error>::custom(err.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use chrono::Utc;
|
||||
use poem::{web::Data, Error, Result};
|
||||
use poem_openapi::{payload::Json, Object, OpenApi};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::{
|
||||
api::{tags::ApiTags, token::Token},
|
||||
State,
|
||||
};
|
||||
|
||||
#[derive(Debug, Object)]
|
||||
struct CheckLicenseRequest {
|
||||
license: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object)]
|
||||
struct SaveLicenseRequest {
|
||||
license: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object, Clone)]
|
||||
pub struct LicenseReply {
|
||||
pub domains: Vec<String>,
|
||||
pub user_limit: u32,
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub expired_at: chrono::DateTime<Utc>,
|
||||
pub sign: bool,
|
||||
pub base58: String,
|
||||
}
|
||||
|
||||
pub struct ApiLicense;
|
||||
|
||||
pub const VOCE_LICENSE_PUBLIC_KEY_PEM: &str = r#"-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEApqGLPAiVzx42qRkjDGqCT4+BrS3BReJA7UAXQt3YNfw2HIB+CJSD
|
||||
F22KnpqmnsaLWmxrUP1Q+ttb+fZhMZ569s5ZLs9h6pq2oTBK8kBUKz127rpwHSpG
|
||||
VnuGbkPB4NUcTOYiDTLT7iD9NSN38Cr1ITTD3+4EiSiCuf9aUpggfo06fqF69ebD
|
||||
C0pPSTRvIDgKrJiku93c3d1uDq1DWfYKu3GP23ie5+3WwQcsd/XG/0xyMk1hfVQJ
|
||||
qTf5Z2rVdmhVGt0XjV6cmaVshJOxGeoAubPLJX4G4DLTvXKGy/WlQlQTqIBz8xUB
|
||||
dnwtOymXGQpaS/Vfo0q1kGzZoXsCx3v7BQIDAQAB
|
||||
-----END RSA PUBLIC KEY-----"#;
|
||||
|
||||
#[OpenApi(prefix_path = "/license", tag = "ApiTags::License")]
|
||||
impl ApiLicense {
|
||||
/// Check the license is valid
|
||||
#[oai(path = "/check", method = "post")]
|
||||
async fn check(
|
||||
&self,
|
||||
_state: Data<&State>,
|
||||
req: Json<CheckLicenseRequest>,
|
||||
) -> Result<Json<LicenseReply>> {
|
||||
let license_bs58 = req.license.clone();
|
||||
let license = vc_license::License::from_string(license_bs58.clone())
|
||||
.map_err(|_err| Error::from_status(StatusCode::BAD_REQUEST))?;
|
||||
let sign_is_ok =
|
||||
vc_license::rsa_check_license_bs58(&license_bs58, VOCE_LICENSE_PUBLIC_KEY_PEM).is_ok();
|
||||
|
||||
Ok(Json(LicenseReply {
|
||||
domains: license.domains.clone(),
|
||||
user_limit: license.user_limit,
|
||||
created_at: license.created_at,
|
||||
expired_at: license.expired_at,
|
||||
sign: sign_is_ok,
|
||||
base58: license_bs58,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Save the license
|
||||
#[oai(path = "/", method = "put")]
|
||||
async fn put(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
req: Json<SaveLicenseRequest>,
|
||||
) -> Result<Json<bool>> {
|
||||
let mut license_path = state.config.system.data_dir.clone();
|
||||
license_path.push("license");
|
||||
// if !license_path.exists() || (token.is_some() && token.unwrap().is_admin) {
|
||||
if !license_path.exists() || token.is_admin {
|
||||
crate::license::update_license(&state, &req.license)
|
||||
.await
|
||||
.map_err(|_err| Error::from_status(StatusCode::BAD_REQUEST))?;
|
||||
}
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// Get the license
|
||||
#[oai(path = "/", method = "get")]
|
||||
async fn get(&self, state: Data<&State>) -> Result<Json<LicenseReply>> {
|
||||
let mut license_path = state.config.system.data_dir.clone();
|
||||
license_path.push("license");
|
||||
let license_bs58 = tokio::fs::read_to_string(license_path)
|
||||
.await
|
||||
.map_err(|_err| Error::from_status(StatusCode::BAD_REQUEST))?;
|
||||
let license = vc_license::License::from_string(license_bs58.clone())
|
||||
.map_err(|_err| Error::from_status(StatusCode::BAD_REQUEST))?;
|
||||
let sign_is_ok =
|
||||
vc_license::rsa_check_license_bs58(&license_bs58, VOCE_LICENSE_PUBLIC_KEY_PEM).is_ok();
|
||||
|
||||
Ok(Json(LicenseReply {
|
||||
domains: license.domains.clone(),
|
||||
user_limit: license.user_limit,
|
||||
created_at: license.created_at,
|
||||
expired_at: license.expired_at,
|
||||
sign: sign_is_ok,
|
||||
base58: license_bs58,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_harness::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_license() {
|
||||
let server = TestServer::new().await;
|
||||
let resp = server
|
||||
.post("/api/license/check")
|
||||
.body_json(&json!({
|
||||
"license": "37w6gXBCKnuPHT6Np1QjhUDRacerHsz9eY1UKWVQyBiiHjLPJ96fBTMwKRAZW5USxkDcT73CPAejwrnoomcj2ZzC5m9hb7H2Q6rTxwzenWoJ5Va2dP3AY84NkZt15maJJcDxRnGZSURrEixFHtYfvk5neEUnxY5tDz2fjD42fWo5UnE4n8bjTgCgAFdS4AxMCmuFaVbidgnTEeNdLjwpdP3v4nQwYX9d211Zmiy81UYq2pJfrksAcP4Ew34jAgB1etAvqYnT2fGvUWtA7DfG6yMgx2Hbo52Upo8TMQNnNkAv4ZDHF1AZ8NqXScAXCK7v5MpdkfggrRepwViovnVKku5XSa794aoXtx9q8fQuKUvAShTYNA2qVm5EYZTNpMTgHAtyAuABJN3DFpT8jJiSjQ3pb31Nh5v2yDKGDhYzBgdwpQmKMp82GakMxCKFmhM7E74VEi4avFed4UdqtQzf2iFnobNs3hJyqT5zys9Yws1ff9iY2bUqP9HRNWtzamPFvWSEt7AgSqRfe1cEuJLhU1SDL2zVgydD69FEKfmrTyR43YfRz8mVjuNLcbKmwgNQk4145SpsZ59nrRdfjTo3wqhbSmoip53mhDaVpMkiWzbESy1QrD6ow75C3SNzUgxmoBWceFPgzcZbQixLWVoh2ofUf4yJqw3JqoaJFrrFUAjVafQLN4hKhrSSoNbkrFjM5rGmZ87ZVFkkLrLez6vCGbxFfK8KeiXDBeq4H1VKRmuy2G9VVDYLNvGFfNpy2YLYnsiXCY8rEyGctteU9i94jbHn1zN9vinyBcgkY",
|
||||
}))
|
||||
.header("Referer", "http://localhost/")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let json = resp.json().await;
|
||||
assert_eq!(
|
||||
json.value().object().get("domains").string_array()[0],
|
||||
"www.domain.com"
|
||||
);
|
||||
assert!(json.value().object().get("sign").bool());
|
||||
|
||||
let server = TestServer::new().await;
|
||||
let resp = server
|
||||
.post("/api/license/check")
|
||||
// .header("X-API-Key", &token1)
|
||||
.body_json(&json!({
|
||||
"license": "bad",
|
||||
}))
|
||||
.header("Referer", "http://localhost/")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_license() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
let resp = server
|
||||
.put("/api/license")
|
||||
.header("Referer", "http://localhost/")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body_json(&json!({
|
||||
"license": "2mLNGnEXac5XNmKftu2P8p9hiaqReiUTscKftwNJuKQyLLBYh7JZ6JzC4Vsehw375PfgAksxtLCFy9dLvetiN7nKDtsQGBj5VBhJ9XPtKsbbfrQSyG2bPdaEyM2KuH6YhvnhfSMbnA5deVH5C4uJ8zkwckFPqchmeDiwXobJeNKKRYzpkef5ggS7WeRnWNRrtXjEXkqsYW1aBb3bmAGR9XQzJcyvGYPAmDwRTLfiyNKCmma1ADeQAyhsooiBB4Q56XQ7cBs55VrUPHmvEisaeNnwU4Y9cihvWRz8HfdFhNs2dsCR2hSnDG6mEbtcoTVVaFonhQGKb9nMrDcFiNoidiSazpUWqVHG1SbcCNiwQweZ8VYRxMDVYXFLTXX2rg17jsY4mMvVHp5sqMjHvQRH4ueZtjVHVRYq4Ra21VUZBMcbLmD56RB1nMveWbAWuppENKtvg5MdByVC9Ah5oca34irHGoJMN2DrXCBxLrf21ushhY1gbXw5f4KETaod2EogivongR3g1UoU3FtiKzXbLMspGzJnBzZeij9UdqBNP8jnSiu667bpGYJSAMjJzPXtZ4oL5U5fFY14wQ5ssLssvQx5XyeGBRMKUphZT5EXL9VyTHYX2EU3Z9nQRNpumXkn4PPUQwZcbJbAeEkRbZDEsiG6g3fwFovfZffXkm5w8qCZ37DuaZkp4FgeLjNHgg3jyLM4VyirWgLkxQ2y7qL7hYSBQPzqpQVfkfYiZY9Tw4WG6cZrvUSmmnaLpDJBwwXxCxg1GaqoypusBMT5Mhw22PcLkmN8e8zS1NaXm3KVijukGQAaHTAc4EYDEASbjiSCavMipqd8Mbsj6j",
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
}
|
||||
|
||||
// #[tokio::test]
|
||||
// async fn test_license_user_limit() {
|
||||
// let server = TestServer::new().await;
|
||||
// let state = server.state();
|
||||
// crate::license::load_license(&state).await.unwrap();
|
||||
//
|
||||
// // test user limit
|
||||
// let admin_token = server.login_admin().await;
|
||||
// for i in 0..30 {
|
||||
// // user default limit: 20
|
||||
// server.create_user(&admin_token, format!("user{}@voce.chat",
|
||||
// i)).await; }
|
||||
//
|
||||
// let resp = server
|
||||
// .post("/api/user/send_login_magic_link")
|
||||
// .query("email", &"xx@xx.com")
|
||||
// .header("Referer", "http://localhost/")
|
||||
// .send()
|
||||
// .await;
|
||||
// // resp.assert_text("").await;
|
||||
// resp.assert_text("License error: Users reached limit.").await;
|
||||
// }
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_license_invalid_sign() {
|
||||
// test sign invalid
|
||||
let server = TestServer::new().await;
|
||||
let state = server.state();
|
||||
crate::license::load_license(state).await.unwrap();
|
||||
let invalid_sign_license = "Jym9MkwzuPBfKKPkYVKJfGeGVbpx8pHeWrLM5zPNoSf3GrrwNCowRGgowUX2LULLzMpdLUcSvrHTHLpYRhKDc5JFyPVNqoKxiWfcCwGzn";
|
||||
crate::license::update_license(state, invalid_sign_license)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = server
|
||||
.post("/api/user/send_login_magic_link")
|
||||
.query("email", &"xx@xx.com")
|
||||
.header("Referer", "http://www.domain.com/")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_text("License error: Sign invalid.").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_license_invalid_sign2() {
|
||||
// test sign invalid
|
||||
let server = TestServer::new().await;
|
||||
let state = server.state();
|
||||
crate::license::load_license(state).await.unwrap();
|
||||
let invalid_sign_license = "Jym9MkwzuPBfKKPkYVKJfGeGVbpx8pHeWrLM5zPNoSf3GrrwNCowRGgowUX2LULLzMpdLUcSvrHTHLpYRhKDc5JFyPVNqoKxiWfcCwGzn";
|
||||
crate::license::update_license(state, invalid_sign_license)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = server
|
||||
.post("/api/token/login")
|
||||
.body_json(&json!({
|
||||
"credential": {
|
||||
"type": "magiclink",
|
||||
"magic_token": "test",
|
||||
"extra_name": "jack"
|
||||
},
|
||||
"device": "web",
|
||||
"device_token": "device token",
|
||||
}))
|
||||
.header("Referer", "http://www.domain.com/")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_text("License error: Sign invalid.").await;
|
||||
}
|
||||
}
|
||||
+1019
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
use poem::{error::InternalServerError, http::StatusCode, web::Data, Error, Result};
|
||||
use poem_openapi::{
|
||||
param::{Header, Path},
|
||||
payload::Json,
|
||||
ApiResponse, Object, OpenApi,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
message::{
|
||||
parse_properties_from_base64, send_message, MessageDetail, MessageReaction,
|
||||
MessageReactionDelete, MessageReactionDetail, MessageReactionEdit, MessageReactionLike,
|
||||
MessageReply, SendMessageRequest,
|
||||
},
|
||||
tags::ApiTags,
|
||||
token::Token,
|
||||
ChatMessagePayload, DateTime, MessageTarget, MessageTargetGroup, MessageTargetUser,
|
||||
},
|
||||
middleware::guest_forbidden,
|
||||
State,
|
||||
};
|
||||
|
||||
pub struct ApiMessage;
|
||||
|
||||
#[derive(Debug, Object)]
|
||||
struct LikeMessageRequest {
|
||||
action: String,
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum ReactionApiResponse {
|
||||
/// Success
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<i64>),
|
||||
/// Target user does not exist
|
||||
#[oai(status = 404)]
|
||||
MessageDoesNotExist,
|
||||
#[oai(status = 403)]
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/message", tag = "ApiTags::Message")]
|
||||
impl ApiMessage {
|
||||
/// Edit message
|
||||
#[oai(path = "/:mid/edit", method = "put", transform = "guest_forbidden")]
|
||||
async fn edit(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
mid: Path<i64>,
|
||||
#[oai(name = "X-Properties")] properties: Header<Option<String>>,
|
||||
req: SendMessageRequest,
|
||||
) -> Result<ReactionApiResponse> {
|
||||
let properties = parse_properties_from_base64(properties.0);
|
||||
let content = req.into_chat_message_content(&state, properties).await?;
|
||||
do_reaction(
|
||||
&state,
|
||||
token,
|
||||
mid.0,
|
||||
MessageReactionDetail::Edit(MessageReactionEdit { content }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Edit message
|
||||
#[oai(path = "/:mid/like", method = "put", transform = "guest_forbidden")]
|
||||
async fn like(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
mid: Path<i64>,
|
||||
req: Json<LikeMessageRequest>,
|
||||
) -> Result<ReactionApiResponse> {
|
||||
let like = MessageReactionLike {
|
||||
action: req.0.action,
|
||||
};
|
||||
if !like.check() {
|
||||
return Err(Error::from_status(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
do_reaction(&state, token, mid.0, MessageReactionDetail::Like(like)).await
|
||||
}
|
||||
|
||||
/// Delete message
|
||||
#[oai(path = "/:mid", method = "delete", transform = "guest_forbidden")]
|
||||
async fn delete(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
mid: Path<i64>,
|
||||
) -> Result<ReactionApiResponse> {
|
||||
do_reaction(
|
||||
&state,
|
||||
token,
|
||||
mid.0,
|
||||
MessageReactionDetail::Delete(MessageReactionDelete {}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reply message
|
||||
#[oai(path = "/:mid/reply", method = "post", transform = "guest_forbidden")]
|
||||
async fn reply(
|
||||
&self,
|
||||
state: Data<&State>,
|
||||
token: Token,
|
||||
mid: Path<i64>,
|
||||
#[oai(name = "X-Properties")] properties: Header<Option<String>>,
|
||||
req: SendMessageRequest,
|
||||
) -> Result<ReactionApiResponse> {
|
||||
let properties = parse_properties_from_base64(properties.0);
|
||||
let content = req.into_chat_message_content(&state, properties).await?;
|
||||
|
||||
let msg_data = match state
|
||||
.msg_db
|
||||
.messages()
|
||||
.get(mid.0)
|
||||
.map_err(InternalServerError)?
|
||||
{
|
||||
Some(data) => data,
|
||||
None => return Ok(ReactionApiResponse::MessageDoesNotExist),
|
||||
};
|
||||
let payload =
|
||||
serde_json::from_slice::<ChatMessagePayload>(&msg_data).map_err(InternalServerError)?;
|
||||
|
||||
if !matches!(
|
||||
&payload.detail,
|
||||
MessageDetail::Normal(_) | MessageDetail::Reply(_)
|
||||
) {
|
||||
return Ok(ReactionApiResponse::Forbidden);
|
||||
}
|
||||
|
||||
match payload.target {
|
||||
MessageTarget::User(MessageTargetUser { uid }) => {
|
||||
if uid != token.uid && payload.from_uid != token.uid {
|
||||
return Ok(ReactionApiResponse::Forbidden);
|
||||
}
|
||||
}
|
||||
MessageTarget::Group(MessageTargetGroup { gid }) => {
|
||||
let cache = state.cache.read().await;
|
||||
let group = match cache.groups.get(&gid) {
|
||||
Some(group) => group,
|
||||
None => return Ok(ReactionApiResponse::Forbidden),
|
||||
};
|
||||
if !group.contains_user(token.uid) {
|
||||
return Ok(ReactionApiResponse::Forbidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_payload = ChatMessagePayload {
|
||||
from_uid: token.uid,
|
||||
created_at: DateTime::now(),
|
||||
target: get_target(token.uid, &payload),
|
||||
detail: MessageDetail::Reply(MessageReply {
|
||||
mid: mid.0,
|
||||
content,
|
||||
}),
|
||||
};
|
||||
let new_mid = send_message(&state, new_payload).await?;
|
||||
Ok(ReactionApiResponse::Ok(Json(new_mid)))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_target(current_uid: i64, payload: &ChatMessagePayload) -> MessageTarget {
|
||||
match payload.target {
|
||||
MessageTarget::User(MessageTargetUser { uid }) => {
|
||||
MessageTarget::user(if uid == current_uid {
|
||||
payload.from_uid
|
||||
} else {
|
||||
uid
|
||||
})
|
||||
}
|
||||
MessageTarget::Group(MessageTargetGroup { gid }) => MessageTarget::group(gid),
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_reaction(
|
||||
state: &State,
|
||||
token: Token,
|
||||
mid: i64,
|
||||
detail: MessageReactionDetail,
|
||||
) -> Result<ReactionApiResponse> {
|
||||
let msg_data = match state
|
||||
.msg_db
|
||||
.messages()
|
||||
.get(mid)
|
||||
.map_err(InternalServerError)?
|
||||
{
|
||||
Some(data) => data,
|
||||
None => return Ok(ReactionApiResponse::MessageDoesNotExist),
|
||||
};
|
||||
let payload =
|
||||
serde_json::from_slice::<ChatMessagePayload>(&msg_data).map_err(InternalServerError)?;
|
||||
if !detail.can_reaction(token.uid, token.is_admin, &payload) {
|
||||
return Ok(ReactionApiResponse::Forbidden);
|
||||
}
|
||||
|
||||
let new_payload = ChatMessagePayload {
|
||||
from_uid: token.uid,
|
||||
created_at: DateTime::now(),
|
||||
target: get_target(token.uid, &payload),
|
||||
detail: MessageDetail::Reaction(MessageReaction { mid, detail }),
|
||||
};
|
||||
let new_mid = send_message(state, new_payload).await?;
|
||||
Ok(ReactionApiResponse::Ok(Json(new_mid)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::test_harness::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reaction_edit() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
let user1 = server.create_user(&admin_token, "user1@voce.chat").await;
|
||||
let user1_token = server.login("user1@voce.chat").await;
|
||||
let mut user1_events = server.subscribe_events(&user1_token, Some(&["chat"])).await;
|
||||
|
||||
let mid1 = server.send_text_to_user(&admin_token, user1, "a").await;
|
||||
|
||||
let resp = server
|
||||
.put(format!("/api/message/{}/edit", mid1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.content_type("text/plain")
|
||||
.body("b")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let mid2 = resp.json().await.value().i64();
|
||||
|
||||
let msg = user1_events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid1);
|
||||
msg.get("from_uid").assert_i64(1);
|
||||
msg.get("target").object().get("uid").assert_i64(user1);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("normal");
|
||||
detail.get("content_type").assert_string("text/plain");
|
||||
detail.get("content").assert_string("a");
|
||||
|
||||
let msg = user1_events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid2);
|
||||
msg.get("from_uid").assert_i64(1);
|
||||
msg.get("target").object().get("uid").assert_i64(user1);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("reaction");
|
||||
detail.get("mid").assert_i64(mid1);
|
||||
let reaction_detail = detail.get("detail").object();
|
||||
reaction_detail.get("type").assert_string("edit");
|
||||
reaction_detail.get("content").assert_string("b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reaction_like() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
let user1 = server.create_user(&admin_token, "user1@voce.chat").await;
|
||||
let user1_token = server.login("user1@voce.chat").await;
|
||||
let mut admin_events = server.subscribe_events(&admin_token, Some(&["chat"])).await;
|
||||
let mut user1_events = server.subscribe_events(&user1_token, Some(&["chat"])).await;
|
||||
|
||||
let mid1 = server.send_text_to_user(&admin_token, user1, "a").await;
|
||||
|
||||
for events in [&mut admin_events, &mut user1_events] {
|
||||
let msg = events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid1);
|
||||
msg.get("from_uid").assert_i64(1);
|
||||
msg.get("target").object().get("uid").assert_i64(user1);
|
||||
}
|
||||
|
||||
let resp = server
|
||||
.put(format!("/api/message/{}/like", mid1))
|
||||
.header("X-API-Key", &admin_token)
|
||||
.content_type("application/json")
|
||||
.body_json(&json!({
|
||||
"action": "❤️"
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let mid2 = resp.json().await.value().i64();
|
||||
|
||||
let resp = server
|
||||
.put(format!("/api/message/{}/like", mid1))
|
||||
.header("X-API-Key", &user1_token)
|
||||
.content_type("application/json")
|
||||
.body_json(&json!({
|
||||
"action": "🚀"
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let mid3 = resp.json().await.value().i64();
|
||||
|
||||
for events in [&mut admin_events, &mut user1_events] {
|
||||
let msg = events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid2);
|
||||
msg.get("from_uid").assert_i64(1);
|
||||
msg.get("target").object().get("uid").assert_i64(user1);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("reaction");
|
||||
detail.get("mid").assert_i64(mid1);
|
||||
let reaction_detail = detail.get("detail").object();
|
||||
reaction_detail.get("type").assert_string("like");
|
||||
reaction_detail.get("action").assert_string("❤️");
|
||||
|
||||
let msg = events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid3);
|
||||
msg.get("from_uid").assert_i64(user1);
|
||||
msg.get("target").object().get("uid").assert_i64(1);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("reaction");
|
||||
detail.get("mid").assert_i64(mid1);
|
||||
let reaction_detail = detail.get("detail").object();
|
||||
reaction_detail.get("type").assert_string("like");
|
||||
reaction_detail.get("action").assert_string("🚀");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reply() {
|
||||
let server = TestServer::new().await;
|
||||
let admin_token = server.login_admin().await;
|
||||
let user1 = server.create_user(&admin_token, "user1@voce.chat").await;
|
||||
let user1_token = server.login("user1@voce.chat").await;
|
||||
let mut user1_events = server.subscribe_events(&user1_token, Some(&["chat"])).await;
|
||||
|
||||
let mid1 = server.send_text_to_user(&admin_token, user1, "a").await;
|
||||
|
||||
let msg = user1_events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid1);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("normal");
|
||||
detail.get("content_type").assert_string("text/plain");
|
||||
detail.get("content").assert_string("a");
|
||||
|
||||
let resp = server
|
||||
.post(format!("/api/message/{}/reply", mid1))
|
||||
.content_type("text/plain")
|
||||
.header("X-API-Key", &admin_token)
|
||||
.body("b")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let mid2 = resp.json().await.value().i64();
|
||||
|
||||
let msg = user1_events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid2);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("reply");
|
||||
detail.get("mid").assert_i64(mid1);
|
||||
detail.get("content_type").assert_string("text/plain");
|
||||
detail.get("content").assert_string("b");
|
||||
|
||||
let resp = server
|
||||
.post(format!("/api/message/{}/reply", mid1))
|
||||
.content_type("text/plain")
|
||||
.header("X-API-Key", &user1_token)
|
||||
.body("c")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let mid3 = resp.json().await.value().i64();
|
||||
|
||||
let msg = user1_events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid3);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("reply");
|
||||
detail.get("mid").assert_i64(mid1);
|
||||
detail.get("content_type").assert_string("text/plain");
|
||||
detail.get("content").assert_string("c");
|
||||
|
||||
let resp = server
|
||||
.post(format!("/api/message/{}/reply", mid3))
|
||||
.content_type("text/plain")
|
||||
.header("X-API-Key", &user1_token)
|
||||
.body("d")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let mid4 = resp.json().await.value().i64();
|
||||
|
||||
let msg = user1_events.next().await.unwrap();
|
||||
let msg = msg.value().object();
|
||||
msg.get("mid").assert_i64(mid4);
|
||||
let detail = msg.get("detail").object();
|
||||
detail.get("type").assert_string("reply");
|
||||
detail.get("mid").assert_i64(mid3);
|
||||
detail.get("content_type").assert_string("text/plain");
|
||||
detail.get("content").assert_string("d");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use poem_openapi::{OpenApi, OpenApiService};
|
||||
|
||||
mod admin_agora;
|
||||
mod admin_fcm;
|
||||
mod admin_github_auth;
|
||||
mod admin_google_auth;
|
||||
mod admin_login;
|
||||
mod admin_smtp;
|
||||
mod admin_system;
|
||||
mod admin_user;
|
||||
mod archive;
|
||||
mod bot;
|
||||
mod datetime;
|
||||
mod favorite;
|
||||
mod group;
|
||||
mod langid;
|
||||
mod license;
|
||||
mod message;
|
||||
mod message_api;
|
||||
mod resource;
|
||||
mod tags;
|
||||
mod token;
|
||||
mod user;
|
||||
mod user_log_action;
|
||||
|
||||
pub use admin_agora::AgoraConfig;
|
||||
pub use admin_fcm::FcmConfig;
|
||||
pub use admin_login::{LoginConfig, WhoCanSignUp};
|
||||
pub use admin_smtp::SmtpConfig;
|
||||
pub use admin_system::{FrontendUrlConfig, Metrics, OrganizationConfig};
|
||||
pub use admin_user::{User, UserDevice};
|
||||
pub use archive::Archive;
|
||||
pub use datetime::DateTime;
|
||||
pub use group::{Group, PinnedMessage};
|
||||
pub use langid::LangId;
|
||||
pub use message::{
|
||||
get_merged_message, BurnAfterReadingGroup, BurnAfterReadingUser, ChatMessage,
|
||||
ChatMessagePayload, GroupChangedMessage, HeartbeatMessage, JoinedGroupMessage,
|
||||
KickFromGroupMessage, KickFromGroupReason, KickMessage, KickReason, Message, MessageDetail,
|
||||
MessageTarget, MessageTargetGroup, MessageTargetUser, MuteGroup, MuteUser, ReadIndexGroup,
|
||||
ReadIndexUser, RelatedGroupsMessage, UserJoinedGroupMessage, UserLeavedGroupMessage,
|
||||
UserSettingsChangedMessage, UserSettingsMessage, UserState, UserStateChangedMessage,
|
||||
UserUpdateLog, UsersStateMessage, UsersUpdateLogMessage,
|
||||
};
|
||||
pub use resource::FileMeta;
|
||||
pub use token::{CurrentUser, Token};
|
||||
pub use user::{
|
||||
CreateUserConflictReason, CreateUserResponse, UpdateUserResponse, UserConflict, UserInfo,
|
||||
};
|
||||
pub use user_log_action::UpdateAction;
|
||||
|
||||
pub fn create_api_service() -> OpenApiService<impl OpenApi, ()> {
|
||||
OpenApiService::new(
|
||||
(
|
||||
token::ApiToken,
|
||||
user::ApiUser,
|
||||
group::ApiGroup,
|
||||
admin_user::ApiAdminUser,
|
||||
resource::ApiResource,
|
||||
message_api::ApiMessage,
|
||||
favorite::ApiFavorite,
|
||||
license::ApiLicense,
|
||||
admin_system::ApiAdminSystem,
|
||||
admin_agora::ApiAdminAgora,
|
||||
admin_fcm::ApiAdminFirebase,
|
||||
admin_smtp::ApiAdminSmtp,
|
||||
admin_login::ApiAdminLogin,
|
||||
admin_google_auth::ApiAdminGoogleAuth,
|
||||
admin_github_auth::ApiAdminGithubAuth,
|
||||
bot::ApiBot,
|
||||
),
|
||||
"Voce Chat",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
)
|
||||
}
|
||||
+1105
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
use poem_openapi::Tags;
|
||||
|
||||
#[derive(Tags)]
|
||||
pub enum ApiTags {
|
||||
/// Token operations
|
||||
Token,
|
||||
|
||||
/// User operations
|
||||
User,
|
||||
|
||||
/// Group operations
|
||||
Group,
|
||||
|
||||
/// Message operations
|
||||
Message,
|
||||
|
||||
/// Resource operations
|
||||
Resource,
|
||||
|
||||
/// Favorite archive operations
|
||||
Favorite,
|
||||
|
||||
/// License operations
|
||||
License,
|
||||
|
||||
/// User management operations
|
||||
AdminUser,
|
||||
|
||||
/// System management operations
|
||||
AdminSystem,
|
||||
|
||||
/// Agora management operations
|
||||
AdminAgora,
|
||||
|
||||
/// Firebase management operations
|
||||
AdminFirebase,
|
||||
|
||||
/// Smtp management operations
|
||||
AdminSmtp,
|
||||
|
||||
/// Login management operations
|
||||
AdminLogin,
|
||||
|
||||
/// Google auth management operations
|
||||
AdminGoogleAuth,
|
||||
|
||||
/// Google auth management operations
|
||||
AdminGithubAuth,
|
||||
|
||||
/// Bot operations
|
||||
Bot,
|
||||
}
|
||||
+2123
File diff suppressed because it is too large
Load Diff
+4186
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user