remove fcm official

This commit is contained in:
suhan1996
2023-03-03 12:03:14 -05:00
commit 4f14fe0d59
111 changed files with 27906 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
DATABASE_URL=sqlite:./data/db/db.sqlite
+193
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
target
/data
.idea
build/docker/config
build/docker/wwwroot
build/docker/*.zip
build/docker/vocechat-server
wwwroot
+12
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -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"
+81
View File
@@ -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
+16
View File
@@ -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"]
+29
View File
@@ -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
+17
View File
@@ -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
+27
View File
@@ -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
+40
View File
@@ -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"
+238
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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-----
+17
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
[system]
server_key = "a secret key"
data_dir = "./data"
[network]
bind = "0.0.0.0:3000"
tls = true
+46
View File
@@ -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"
+223
View File
@@ -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>
+224
View File
@@ -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>
+12
View File
@@ -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"
+98
View File
@@ -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,
],
)
}
+19
View File
@@ -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"] }
+153
View File
@@ -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();
}
}
+9
View File
@@ -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,
}
+94
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
mod client;
mod credentials;
mod jwt;
pub use client::FcmClient;
pub use credentials::ApplicationCredentials;
+8
View File
@@ -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]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}
+13
View File
@@ -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"
+149
View File
@@ -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!");
}
}
}
+768
View File
@@ -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"
+17
View File
@@ -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
+43
View File
@@ -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);
+36
View File
@@ -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)
}
}
+10
View File
@@ -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>;
+8
View File
@@ -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;
+238
View File
@@ -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))
}
+53
View File
@@ -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
}
+27
View File
@@ -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"]
+40
View File
@@ -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`.
+17
View File
@@ -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()
}
}
}
+52
View File
@@ -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 {
""
}
}
+341
View File
@@ -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);
}
}
+21
View File
@@ -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()
}
}
}
+22
View File
@@ -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;
+20
View File
@@ -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>,
}
+19
View File
@@ -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()
}
}
}
+13
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Jwt(#[from] jwt::Error),
#[error("expired")]
Expired,
}
+97
View File
@@ -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);
}
}
+18
View File
@@ -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"
+265
View File
@@ -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);
}
}
+2
View File
@@ -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)
+199
View File
@@ -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”. Its 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
Weve 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 19821984=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 cant deliver a request that way, or this software doesnt 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 cant 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. Weve almost made more clear that the proposal needs to be in line with whats 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 licensors 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 licensors permission is not necessary for any reasonfor example, because of any applicable exception or limitation to copyrightthen 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.
+82
View File
@@ -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);
+13
View File
@@ -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
);
+16
View File
@@ -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
);
+15
View File
@@ -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);
+6
View File
@@ -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
);
+10
View File
@@ -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);
+13
View File
@@ -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);
+16
View File
@@ -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);
+1
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
alter table
user
add
column webhook_url string;
+9
View File
@@ -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;
+13
View File
@@ -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);
+155
View File
@@ -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))
}
}
+103
View File
@@ -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,
}))
}
}
+101
View File
@@ -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");
}
}
+62
View File
@@ -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))
}
}
+119
View File
@@ -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))
}
}
+88
View File
@@ -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))
}
}
+399
View File
@@ -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();
}
}
+837
View File
@@ -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)
}
}
}
+343
View File
@@ -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
View File
@@ -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))
}
}
+112
View File
@@ -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())
}
}
+279
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -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()))
}
}
+233
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+404
View File
@@ -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");
}
}
+75
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4186
View File
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